diff --git a/.github/workflows/tests-ranges.yml b/.github/workflows/tests-ranges.yml index e20d33d7..7c958684 100644 --- a/.github/workflows/tests-ranges.yml +++ b/.github/workflows/tests-ranges.yml @@ -3,7 +3,9 @@ name: "Test Suite: ranges" on: pull_request: paths: - - 'ranges/*' + - 'ranges/**' + - 'core/**' + - 'aliases/**' branches: - main workflow_dispatch: diff --git a/aliases/README.md b/aliases/README.md index 20e16783..e8b566b0 100644 --- a/aliases/README.md +++ b/aliases/README.md @@ -16,6 +16,7 @@ for (const alias of aliases) { This module returns an `Array` of aliases: +- 'alpha' - This represents the release line in its Alpha phase, if any. Starting with Node.js 27, each release line gets Alpha releases (`vN.0.0-alpha.X.Y.Z`) before its first Current release. - 'current' - This represents the current Node.js release line. - 'lts/latest' - This represents the latest LTS release line. - 'lts/active' - This represents the active LTS release lines. diff --git a/aliases/index.js b/aliases/index.js index 592d1b58..584945b4 100644 --- a/aliases/index.js +++ b/aliases/index.js @@ -1,4 +1,5 @@ const aliases = [ + 'alpha', 'current', 'lts/latest', 'lts/active', diff --git a/ranges/README.md b/ranges/README.md index fa7d3254..da36cb60 100644 --- a/ranges/README.md +++ b/ranges/README.md @@ -14,16 +14,31 @@ async function generateData () { } ``` +## API + +### `ranges(filter, options)` + +- `filter` (String or Array, optional): one or more aliases from [@nodevu/aliases](../aliases/) to limit the result to. Defaults to `'all'`, which returns every range. +- `options` (Object, optional): passed straight to [@nodevu/core](../core/), so you can set things like a custom `fetch` or `now`. + +```js +const data = await ranges('alpha', { now: '2026-12-15' }) +``` + ## Structure This module will return a medium-sized object with the following structure, with each property being in the context of the range it's under: - all: - - versions (Array): An array of versions. + - versions (Array): An array of versions. Leaves out Alpha releases. - newestLts (String): The newest LTS version. - newestSecurity (String): The newest possible security release. - newest (String): The newest version. - oldest (String): The oldest version. +- alpha: + - versions (Array): An array of the Alpha releases (`vN.0.0-alpha.X.Y.Z`) of the release line in its Alpha phase. Alpha releases only ever show up here, never in the other ranges. + - newest (String): The newest version. + - oldest (String): The oldest version. - current: - versions (Array): An array of versions. - oldestSecurity (String): The oldest security release. diff --git a/ranges/index.js b/ranges/index.js index c412b939..8a4de9e6 100644 --- a/ranges/index.js +++ b/ranges/index.js @@ -12,6 +12,11 @@ const defaultRangesObject = { newest: undefined, oldest: undefined, }, + alpha: { + versions: [], + newest: undefined, + oldest: undefined, + }, current: { versions: [], oldestSecurity: undefined, @@ -62,7 +67,7 @@ const defaultRangesObject = { // If you figure one out, or there's one in the future, please feel free // to submit a PR to update this code. -async function generateRanges(filter) { +async function generateRanges(filter, options) { let localFilter = filter; if (typeof localFilter === 'string') { // check to make sure that the filter is valid. if it's not, yeet. @@ -88,9 +93,9 @@ async function generateRanges(filter) { localFilter = 'all'; } - // set up the data from @nodevu/core + // set up the data from @nodevu/core, passing options (like `fetch` and `now`) through - const data = await nodevu(); + const data = await nodevu(options); // deep clone the default object so we don't modify it. // @@ -100,9 +105,25 @@ async function generateRanges(filter) { for (const line in data) { for (const key in data[line].releases) { + // Alpha releases (`vN.0.0-alpha.X.Y.Z`) are pre-releases, so they only + // go into `alpha` and are kept out of every other range + if (data[line].releases[key].alpha?.isAlpha) { + if ( + (localFilter === 'alpha' || + localFilter === 'all' || + localFilter.includes('alpha') || + localFilter.includes('all')) && + data[line].support?.phases.current === 'alpha' + ) { + ranges.alpha.versions.push(key); + } + + continue; + } + // define `all` data if (localFilter === 'all' || localFilter.includes('all')) { - ranges.all.versions.push(key); // put every release into the all array + ranges.all.versions.push(key); // put every non-Alpha release into the all array if ( data[line].releases[key].lts.isLts === true && diff --git a/ranges/package.json b/ranges/package.json index b55bd227..719edabc 100644 --- a/ranges/package.json +++ b/ranges/package.json @@ -7,8 +7,8 @@ "scripts": { "lint": "biome check ./", "lint:write": "biome check ./ --write", - "test": "node --test", - "coverage": "c8 node --test", + "test": "node --test test/*.test.js", + "coverage": "c8 node --test test/*.test.js", "updates:check": "npx npm-check-updates", "updates:update": "npx npm-check-updates -u" }, @@ -23,8 +23,8 @@ }, "homepage": "https://github.com/cutenode/nodevu#readme", "dependencies": { - "@nodevu/aliases": "^0.0.2", - "@nodevu/core": "^0.3.0" + "@nodevu/aliases": "^1.0.0", + "@nodevu/core": "^1.0.0" }, "devDependencies": { "@biomejs/biome": "1.9.4", diff --git a/ranges/test/alpha.test.js b/ranges/test/alpha.test.js new file mode 100644 index 00000000..662aa20a --- /dev/null +++ b/ranges/test/alpha.test.js @@ -0,0 +1,108 @@ +const { deepStrictEqual } = require('node:assert'); +const ranges = require('../index'); +const fixtures = require('./fixtures'); +const { describe, it } = require('node:test'); + +// these tests use made-up data that follows the new release schedule, where +// every release line gets Alpha releases (versioned as `vN.0.0-alpha.X.Y.Z`) +// before its first Current release. +// +// see https://nodejs.org/en/blog/announcements/evolving-the-nodejs-release-schedule +function release(version, date, lts = false) { + return { version, date, lts, files: ['linux-x64', 'src'] }; +} + +const index = [ + release('v27.2.0', '2027-10-20', 'Placeholder'), + release('v27.1.0', '2027-06-01'), + release('v27.0.0', '2027-04-22'), + release('v27.0.0-alpha.1.0.0', '2026-12-08'), + release('v27.0.0-alpha.0.0.0', '2026-10-28'), + release('v26.13.0', '2026-10-28', 'Placeholder'), + release('v26.12.0', '2026-10-14'), +]; + +// the line on the new schedule goes alpha -> start -> lts -> end, with no +// maintenance phase, while the line before it keeps the old schedule. the +// real schedule.json labels the new schedule's lts date as `maintenance` until +// https://github.com/nodejs/Release/pull/1188 lands. +const schedule = { + v26: { + start: '2026-05-05', + lts: '2026-10-28', + maintenance: '2027-10-20', + end: '2029-04-30', + codename: '', + }, + v27: { + alpha: '2026-10-28', + start: '2027-04-22', + lts: '2027-10-20', + end: '2030-04-30', + codename: '', + }, +}; + +// serves the made-up data above instead of core's snapshots +const at = (now) => fixtures.at(now, { index, schedule }); + +describe('check to make sure that Alpha releases work as expected', async () => { + it('should leave Alpha releases out of "all"', async () => { + const data = await ranges('all', at('2026-12-15')); + + deepStrictEqual(data.all.versions, ['v26.13.0', 'v26.12.0']); + deepStrictEqual(data.all.newest, 'v26.13.0'); + deepStrictEqual(data.all.oldest, 'v26.12.0'); + }); + + it('should have the Alpha releases in "alpha" during the Alpha phase', async () => { + deepStrictEqual(await ranges('alpha', at('2026-12-15')), { + alpha: { + versions: ['v27.0.0-alpha.1.0.0', 'v27.0.0-alpha.0.0.0'], + newest: 'v27.0.0-alpha.1.0.0', + oldest: 'v27.0.0-alpha.0.0.0', + }, + }); + + const data = await ranges(undefined, at('2026-12-15')); + deepStrictEqual(data.alpha.versions, [ + 'v27.0.0-alpha.1.0.0', + 'v27.0.0-alpha.0.0.0', + ]); + }); + + it('should not have "alpha" outside of the Alpha phase', async () => { + for (const now of ['2026-10-20', '2027-05-01', '2027-11-01']) { + deepStrictEqual(await ranges('alpha', at(now)), {}, now); + deepStrictEqual((await ranges(undefined, at(now))).alpha, undefined, now); + } + }); + + it('should leave Alpha releases out of "current" once the release line is Current', async () => { + const data = await ranges(undefined, at('2027-05-01')); + + deepStrictEqual(data.current.versions, ['v27.0.0']); + deepStrictEqual(data.current.newest, 'v27.0.0'); + deepStrictEqual(data.all.newest, 'v27.0.0'); + }); + + it('should leave Alpha releases out of the LTS ranges once the release line is LTS', async () => { + const data = await ranges(undefined, at('2027-11-01')); + + deepStrictEqual(data['lts/latest'].versions, ['v27.2.0']); + deepStrictEqual(data['lts/active'].versions, ['v27.2.0', 'v26.13.0']); + deepStrictEqual(data['lts/maintenance'].versions, ['v26.13.0']); + }); + + it('should leave Alpha releases out of "eol" once the release line is End of Life', async () => { + const data = await ranges('eol', at('2030-05-01')); + + deepStrictEqual(data.eol.versions, [ + 'v27.2.0', + 'v27.1.0', + 'v27.0.0', + 'v26.13.0', + 'v26.12.0', + ]); + }); +}); diff --git a/ranges/test/default.test.js b/ranges/test/default.test.js index 1e43e300..cdef3f49 100644 --- a/ranges/test/default.test.js +++ b/ranges/test/default.test.js @@ -1,10 +1,11 @@ const assert = require('node:assert'); const ranges = require('../index'); +const { at } = require('./fixtures'); const { describe, it } = require('node:test'); describe('data should exist as it is defined', async () => { it('versions should have correct types on every property', async () => { - const data = await ranges(); + const data = await ranges(undefined, at()); for (const key in data) { assert.deepStrictEqual(Array.isArray(data[key].versions), true); assert.deepStrictEqual(data[key].versions.length > 0, true); @@ -16,7 +17,7 @@ describe('data should exist as it is defined', async () => { }); it('both newest and oldest on every property should be the correct type', async () => { - const data = await ranges(); + const data = await ranges(undefined, at()); for (const key in data) { assert.deepStrictEqual(typeof data[key].newest, 'string'); assert.deepStrictEqual(typeof data[key].oldest, 'string'); diff --git a/ranges/test/fixtures.js b/ranges/test/fixtures.js new file mode 100644 index 00000000..1b2a0945 --- /dev/null +++ b/ranges/test/fixtures.js @@ -0,0 +1,31 @@ +const index = require('../../core/test/data/static/index.json'); +const schedule = require('../../core/test/data/static/schedule.json'); + +// the default date for tests. it's picked so that current, active LTS, +// maintenance LTS and end of life each have a release line, and each of those +// lines already has a security release, so no range or security field is +// empty. +const now = '2025-09-01'; + +// returns options for `ranges` that serve `data` (core's index.json and +// schedule.json snapshots by default) instead of fetching them, only including +// releases that would be out by `now`, like the real index.json. any other URL +// throws, so tests never touch the network. +function at(date = now, data = { index, schedule }) { + return { + now: date, + fetch: async (url) => { + if (url.endsWith('/schedule.json')) { + return Response.json(data.schedule); + } + + if (url.endsWith('/index.json')) { + return Response.json(data.index.filter((entry) => entry.date <= date)); + } + + throw new Error(`No fixture data for ${url}`); + }, + }; +} + +module.exports = { at }; diff --git a/ranges/test/known.test.js b/ranges/test/known.test.js index 3b6d3808..6c008aec 100644 --- a/ranges/test/known.test.js +++ b/ranges/test/known.test.js @@ -1,18 +1,74 @@ const assert = require('node:assert'); const ranges = require('../index'); +const { at } = require('./fixtures'); const { describe, it } = require('node:test'); describe('check some values we know should exist', async () => { it('should include some known, static values in "all"', async () => { - const data = await ranges(); + const data = await ranges(undefined, at()); assert.deepStrictEqual(data.all.versions.includes('v19.0.0'), true); // a semver major we know exists assert.deepStrictEqual(data.all.versions.includes('v14.21.0'), true); // an LTS release we know happened assert.deepStrictEqual(data.all.versions.includes('v0.1.14'), true); // oldest included data in the array - this is the correct oldest data since it's technically the first entry on nodejs.org/dist/index.json }); it('should include some known, static values in "eol"', async () => { - const data = await ranges(); + const data = await ranges(undefined, at()); assert.deepStrictEqual(data.all.versions.includes('v14.21.0'), true); // an LTS release we know happened assert.deepStrictEqual(data.eol.versions.includes('v0.8.0'), true); // oldest included data in the array - this should be fixed since it's clearly not the oldest in the dataset. }); + + // pins every range's values on the fixture date, so a release line that + // lands in the wrong range fails + it('should put each release line in the right range', async () => { + const data = await ranges(undefined, at()); + const summary = Object.fromEntries( + Object.entries(data).map(([alias, { versions, ...range }]) => [ + alias, + range, + ]), + ); + + assert.deepStrictEqual(summary, { + all: { + newestLts: 'v22.19.0', + newestSecurity: 'v24.4.1', + newest: 'v24.7.0', + oldest: 'v0.1.14', + }, + current: { + oldestSecurity: '24.0.2', + newestSecurity: '24.4.1', + newest: 'v24.7.0', + oldest: 'v24.0.0', + }, + 'lts/latest': { + oldestLts: '22.11.0', + oldestSecurity: '22.4.1', + newestLts: '22.19.0', + newestSecurity: '22.17.1', + newest: 'v22.19.0', + oldest: 'v22.11.0', + }, + 'lts/active': { + oldestLts: '20.9.0', + oldestSecurity: '20.3.1', + newestLts: '22.19.0', + newestSecurity: '22.17.1', + newest: 'v22.19.0', + oldest: 'v20.9.0', + }, + 'lts/maintenance': { + oldestLts: '20.9.0', + oldestSecurity: '20.3.1', + newestLts: '20.19.4', + newestSecurity: '20.19.4', + newest: 'v20.19.4', + oldest: 'v20.9.0', + }, + eol: { + newest: 'v23.11.1', + oldest: 'v0.8.0', + }, + }); + }); }); diff --git a/ranges/test/matrixAlias.test.js b/ranges/test/matrixAlias.test.js index 91c9f9f1..920e311b 100644 --- a/ranges/test/matrixAlias.test.js +++ b/ranges/test/matrixAlias.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert'); const ranges = require('../index'); +const { at } = require('./fixtures'); const { describe, it } = require('node:test'); // this is a non-exhaustive list of sets of aliases. @@ -7,7 +8,7 @@ const { describe, it } = require('node:test'); describe('data should exist as it is defined when calling multiple aliases', async () => { // begin by testing some of the more risky logic in the data construction it('versions should have correct types on every property if an array when "lts/latest" and "lts/maintenance" are passed', async () => { - const data = await ranges(['lts/latest', 'lts/maintenance']); + const data = await ranges(['lts/latest', 'lts/maintenance'], at()); // check lts/latest values assert.deepStrictEqual(Array.isArray(data['lts/latest'].versions), true); @@ -37,7 +38,7 @@ describe('data should exist as it is defined when calling multiple aliases', asy }); it('versions should have correct types on every property if an array when "lts/latest" and "lts/active" are passed', async () => { - const data = await ranges(['lts/latest', 'lts/active']); + const data = await ranges(['lts/latest', 'lts/active'], at()); // check lts/latest values assert.deepStrictEqual(Array.isArray(data['lts/latest'].versions), true); @@ -64,7 +65,7 @@ describe('data should exist as it is defined when calling multiple aliases', asy }); it('versions should have correct types on every property if an array when "lts/maintenance" and "lts/active" are passed', async () => { - const data = await ranges(['lts/maintenance', 'lts/active']); + const data = await ranges(['lts/maintenance', 'lts/active'], at()); // check lts/maintenance values assert.deepStrictEqual( @@ -94,7 +95,10 @@ describe('data should exist as it is defined when calling multiple aliases', asy }); it('versions should have correct types on every property if an array when "lts/maintenance", "lts/active", and "lts/latest" are passed', async () => { - const data = await ranges(['lts/maintenance', 'lts/active', 'lts/latest']); + const data = await ranges( + ['lts/maintenance', 'lts/active', 'lts/latest'], + at(), + ); // check lts/maintenance values assert.deepStrictEqual( @@ -138,7 +142,7 @@ describe('data should exist as it is defined when calling multiple aliases', asy // we want to make sure that we're specifically testing the translations // as a set to ensure that it is direct and clear when they're breaking. it('should pass the "lts" translation ("lts/active" and "lts/maintenance")', async () => { - const data = await ranges(['lts/active', 'lts/maintenance']); + const data = await ranges(['lts/active', 'lts/maintenance'], at()); // check lts/active values assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); @@ -171,7 +175,10 @@ describe('data should exist as it is defined when calling multiple aliases', asy }); it('should pass the "supported" translation ("current", "lts/active", and "lts/maintenance")', async () => { - const data = await ranges(['current', 'lts/active', 'lts/maintenance']); + const data = await ranges( + ['current', 'lts/active', 'lts/maintenance'], + at(), + ); // check current values assert.deepStrictEqual(Array.isArray(data.current.versions), true); @@ -211,7 +218,10 @@ describe('data should exist as it is defined when calling multiple aliases', asy }); it('should pass the "supported" translation ("current", "lts/active", and "lts/maintenance")', async () => { - const data = await ranges(['current', 'lts/active', 'lts/maintenance']); + const data = await ranges( + ['current', 'lts/active', 'lts/maintenance'], + at(), + ); // check current values assert.deepStrictEqual(Array.isArray(data.current.versions), true); @@ -251,12 +261,10 @@ describe('data should exist as it is defined when calling multiple aliases', asy }); it('should pass the "all" translation ("current", "lts/active", "lts/maintenance", and "eol")', async () => { - const data = await ranges([ - 'current', - 'lts/active', - 'lts/maintenance', - 'eol', - ]); + const data = await ranges( + ['current', 'lts/active', 'lts/maintenance', 'eol'], + at(), + ); // check current values assert.deepStrictEqual(Array.isArray(data.current.versions), true); diff --git a/ranges/test/singleAlias.test.js b/ranges/test/singleAlias.test.js index 47996ccb..980310bd 100644 --- a/ranges/test/singleAlias.test.js +++ b/ranges/test/singleAlias.test.js @@ -1,10 +1,11 @@ const assert = require('node:assert'); const ranges = require('../index'); +const { at } = require('./fixtures'); const { describe, it } = require('node:test'); describe('data should exist as it is defined when calling a single alias', async () => { it('versions should have correct types on every property if an array with "all" is passed', async () => { - const data = await ranges(['all']); + const data = await ranges(['all'], at()); assert.deepStrictEqual(Array.isArray(data.all.versions), true); assert.deepStrictEqual(data.all.versions.length > 0, true); assert.deepStrictEqual( @@ -14,7 +15,7 @@ describe('data should exist as it is defined when calling a single alias', async }); it('versions should have correct types on every property if an array with "current" is passed', async () => { - const data = await ranges(['current']); + const data = await ranges(['current'], at()); assert.deepStrictEqual(Array.isArray(data.current.versions), true); assert.deepStrictEqual(data.current.versions.length > 0, true); assert.deepStrictEqual( @@ -24,7 +25,7 @@ describe('data should exist as it is defined when calling a single alias', async }); it('versions should have correct types on every property if an array with "lts/latest" is passed', async () => { - const data = await ranges(['lts/latest']); + const data = await ranges(['lts/latest'], at()); assert.deepStrictEqual(Array.isArray(data['lts/latest'].versions), true); assert.deepStrictEqual(data['lts/latest'].versions.length > 0, true); assert.deepStrictEqual( @@ -40,7 +41,7 @@ describe('data should exist as it is defined when calling a single alias', async }); it('versions should have correct types on every property if an array with "lts/active" is passed', async () => { - const data = await ranges(['lts/active']); + const data = await ranges(['lts/active'], at()); assert.deepStrictEqual(Array.isArray(data['lts/active'].versions), true); assert.deepStrictEqual(data['lts/active'].versions.length > 0, true); assert.deepStrictEqual( @@ -56,7 +57,7 @@ describe('data should exist as it is defined when calling a single alias', async }); it('versions should have correct types on every property if an array with "lts/maintenance" is passed', async () => { - const data = await ranges(['lts/maintenance']); + const data = await ranges(['lts/maintenance'], at()); assert.deepStrictEqual( Array.isArray(data['lts/maintenance'].versions), true, @@ -75,7 +76,7 @@ describe('data should exist as it is defined when calling a single alias', async }); it('versions should have correct types on every property if an array with "eol" is passed', async () => { - const data = await ranges(['eol']); + const data = await ranges(['eol'], at()); assert.deepStrictEqual(Array.isArray(data.eol.versions), true); assert.deepStrictEqual(data.eol.versions.length > 0, true); assert.deepStrictEqual( diff --git a/ranges/test/unknownFilter.test.js b/ranges/test/unknownFilter.test.js index cd47ab8b..bd4b73b8 100644 --- a/ranges/test/unknownFilter.test.js +++ b/ranges/test/unknownFilter.test.js @@ -1,11 +1,12 @@ const assert = require('node:assert'); const ranges = require('../index'); +const { at } = require('./fixtures'); const { describe, it } = require('node:test'); describe('failure states for arguments', async () => { it('should throw an error on a string with an unknown value', async () => { try { - const data = await ranges('poopin'); + const data = await ranges('poopin', at()); if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -17,7 +18,7 @@ describe('failure states for arguments', async () => { it('should throw an error on a string with an unknown value', async () => { try { - const data = await ranges('poopin'); + const data = await ranges('poopin', at()); if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -29,7 +30,7 @@ describe('failure states for arguments', async () => { it('should throw an error on a single unknown array entry', async () => { try { - const data = await ranges(['poopin']); + const data = await ranges(['poopin'], at()); if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -41,7 +42,7 @@ describe('failure states for arguments', async () => { it('should throw an error on an unknown array entry where multiple entries exist and it is the first entry', async () => { try { - const data = await ranges(['poopin', 'lts/active']); + const data = await ranges(['poopin', 'lts/active'], at()); if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -53,7 +54,7 @@ describe('failure states for arguments', async () => { it('should throw an error on an unknown array entry where multiple entries exist and it is the first entry', async () => { try { - const data = await ranges(['lts/active', 'poopin']); + const data = await ranges(['lts/active', 'poopin'], at()); if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( @@ -65,7 +66,7 @@ describe('failure states for arguments', async () => { it('should throw an error on passing an Object', async () => { try { - const data = await ranges({}); + const data = await ranges({}, at()); if (data) throw new Error('Should not have gotten here'); } catch (error) { assert.deepStrictEqual( diff --git a/ranges/test/v.test.js b/ranges/test/v.test.js index 38bea7b3..534926af 100644 --- a/ranges/test/v.test.js +++ b/ranges/test/v.test.js @@ -1,11 +1,12 @@ const assert = require('node:assert'); const ranges = require('../index'); +const { at } = require('./fixtures'); const { describe, it } = require('node:test'); // tests various values to ensure that the start with the correct character describe('check that "v"s are where they should (and are not where they should not be)', async () => { it('should include the letter v before the strings that should have it', async () => { - const data = await ranges(); + const data = await ranges(undefined, at()); // loop over all versions arrays in all sets (all, // current, lts/latest, lts/active, lts/maintenance, @@ -29,7 +30,7 @@ describe('check that "v"s are where they should (and are not where they should n // this probably needs to be fixed. It's a bug in core and it trickles down to ranges. it('is missing the letter v where it should not be... for now', async () => { - const data = await ranges(); + const data = await ranges(undefined, at()); assert.deepStrictEqual(data.current.oldestSecurity.startsWith('v'), false); assert.deepStrictEqual(data.current.newestSecurity.startsWith('v'), false);