From 28a32b8b85eb7c5e67c3693b6a45389ecd012bfc Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 19 Oct 2026 14:32:31 +0200 Subject: [PATCH 1/4] feat: support the Alpha release phase Starting with 27.x, every Node.js release line gets Alpha releases, versioned as `vN.0.0-alpha.X.Y.Z`, before its first Current release, and schedule.json has an `alpha` date for it. Releases now keep their pre-release identifiers. Coercing them turned every Alpha release into `vN.0.0`, so they would overwrite each other, and `vN.0.0` itself once it ships. Every release has an `alpha.isAlpha` flag, like `lts.isLts`, and `support.phases` has the `alpha` date, with `alpha` as the current phase until the release line becomes Current. Release lines on the previous schedule get the same data as before, besides `alpha.isAlpha` being `false` on their releases. This holds for all 872 releases in today's index.json. Refs: https://nodejs.org/en/blog/announcements/evolving-the-nodejs-release-schedule Refs: https://github.com/nodejs/nodejs.org/pull/9223 Assisted-by: Claude Opus 5.5 --- core/README.md | 15 ++++-- core/index.js | 22 ++++++++- core/test/alpha.js | 112 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 core/test/alpha.js diff --git a/core/README.md b/core/README.md index ca7cd87f..dfb20eda 100644 --- a/core/README.md +++ b/core/README.md @@ -77,6 +77,8 @@ The data returned by this module follows a pretty specific structure. Here's a d - `architecture` (`String`) - The name of the architecture for the files in `files`. - `lts` (`Object`): - `isLts`(Boolean) - `true` if the release is an LTS release, `false` if it's not. + - `alpha` (`Object`): + - `isAlpha` (Boolean) - `true` if the release is an Alpha release (like `v27.0.0-alpha.0.0.0`), `false` if it's not. - `security` (`Object`): - `isSecurity` (Boolean) - `true` if the release is a security release, `false` if it's not. - `support` (`Object`) - An`Object`to provide information about the project's support of the given release line, including dates, status, LTS codename, and LTS version info: @@ -84,11 +86,14 @@ The data returned by this module follows a pretty specific structure. Here's a d - `lts` (`Object`) - **Only present if applicable to the given release.** An`Object`that provides LTS versioning information. - `newest` (`String`) - The newest release in the release line that is LTS. _Usually_, this will be the latest release in the line. - `oldest` (`String`) - **Only present if applicable to the given release.** The oldest (first) release in the release line that was flagged as LTS. - - `phases` (`Object`) - Start dates for each phase of a release line's lifecycle: - - `start` (`String`) - The start date of the release line (releases in this phase are `current`). Example: `2021-04-20`. - - `lts` (`String`) - **Only present if the release line is an LTS release.** The start date of the LTS lifecycle (releases in this phase are `lts`). Example: `2021-10-26`. - - `maintenance` (`String`) - **Only present if the release line is an LTS release.** The start date of the maintenance phase of the lifecycle. Example: `2022-10-18`. - - `end` (`String`) - the date the release line is no longer supported. Example: `2024-04-30`. + - `phases` (`Object`) - Information about each phase of a release line's lifecycle: + - `dates` (`Object`) - Start dates for each phase of a release line's lifecycle: + - `alpha` (`String`) - **Only present if the release line has an Alpha phase.** The start date of the Alpha phase, before the first Current release of the release line (releases in this phase are `alpha`). Example: `2026-10-28`. + - `start` (`String`) - The start date of the release line's Current phase (releases in this phase are `current`). Example: `2021-04-20`. + - `lts` (`String`) - **Only present if the release line is an LTS release.** The start date of the LTS lifecycle (releases in this phase are `lts`). Example: `2021-10-26`. + - `maintenance` (`String`) - **Only present if the release line has a maintenance phase.** The start date of the maintenance phase of the lifecycle. Example: `2022-10-18`. + - `end` (`String`) - the date the release line is no longer supported. Example: `2024-04-30`. + - `current` (`String`) - The phase the release line is in, based on `now`: `alpha`, `start`, `lts`, `maintenance`, or `end`. An empty `Object` if no phase has started yet. - `security` (`Object`): - `all` (`Array`) - an `Array` of _all_ security releases in the release line. - `newest` (`String`) - the newest (most recent) security release. Example: `17.3.1`. diff --git a/core/index.js b/core/index.js index 88f625f0..9cdc22fd 100644 --- a/core/index.js +++ b/core/index.js @@ -19,7 +19,11 @@ async function core(options) { const data = {}; Object.keys(versions).map(async (version) => { - const versionSemver = semver.coerce(versions[version].version); + // keep the pre-release identifiers, otherwise every Alpha release (like + // `v27.0.0-alpha.0.0.0`) would be coerced into `v27.0.0` + const versionSemver = semver.coerce(versions[version].version, { + includePrerelease: true, + }); const name = versionSemver.major !== 0 ? `v${versionSemver.major}` @@ -119,6 +123,8 @@ async function core(options) { data[name].support.phases = {}; data[name].support.phases.dates = {}; + data[name].support.phases.dates.alpha = + schedule[name]?.alpha ?? undefined; data[name].support.phases.dates.start = schedule[name]?.start ?? undefined; data[name].support.phases.dates.lts = schedule[name]?.lts ?? undefined; @@ -148,6 +154,16 @@ async function core(options) { data[name].releases[`v${versionSemver.version}`].lts.isLts = !!versions[version].lts; + // # Alpha + // ## define the alpha object in each specific version + // + // Alpha releases are the pre-releases a release line gets before its + // first Current release, versioned as `vN.0.0-alpha.X.Y.Z` + data[name].releases[`v${versionSemver.version}`].alpha = {}; + + data[name].releases[`v${versionSemver.version}`].alpha.isAlpha = + versionSemver.prerelease[0] === 'alpha'; + // # Security // ## define the release-line specific security object if (!data[name].security) { @@ -191,6 +207,8 @@ async function determineCurrentReleasePhase(now, dates = {}) { // `true` is in the past // `false` is in the future const isoified = { + alpha: + isInPast(DateTime.fromISO(dates.alpha).diff(now).toMillis()) ?? undefined, start: isInPast(DateTime.fromISO(dates.start).diff(now).toMillis()) ?? undefined, lts: @@ -208,7 +226,7 @@ async function determineCurrentReleasePhase(now, dates = {}) { // iterate over the past/future object and set the above variable to whatever the first date is in the future. for (const phase of Object.keys(isoified)) { // since we're looping, the last true is the current phase - // since the start date will always be in the past + // since the start (or alpha) date will always be in the past if (isoified[phase] === true) { result = phase; } diff --git a/core/test/alpha.js b/core/test/alpha.js new file mode 100644 index 00000000..9d51f0fe --- /dev/null +++ b/core/test/alpha.js @@ -0,0 +1,112 @@ +const { deepStrictEqual } = require('node:assert'); +const nodevu = require('../index'); +const { describe, it } = require('node:test'); + +// these tests use made-up data that follows the release schedule starting with +// 27.x, 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) { + return { version, date, files: ['linux-x64', 'src'] }; +} + +const index = [ + 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.12.0', '2026-10-14'), +]; + +// 26.x follows the previous release schedule, while 27.x gets an Alpha phase +// and goes straight from Current to LTS, without a maintenance phase +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: '', + }, +}; + +async function mockFetch(url) { + return Response.json(url.endsWith('schedule.json') ? schedule : index); +} + +describe('check to make sure that Alpha releases work as expected', async () => { + it('should have every Alpha release, with its full version', async () => { + const data = await nodevu({ fetch: mockFetch }); + + deepStrictEqual(Object.keys(data.v27.releases), [ + 'v27.0.0', + 'v27.0.0-alpha.1.0.0', + 'v27.0.0-alpha.0.0.0', + ]); + deepStrictEqual(data.v27.releases['v27.0.0'].releaseDate, '2027-04-22'); + deepStrictEqual(data.v27.releases['v27.0.0-alpha.1.0.0'].semver, { + raw: '27.0.0-alpha.1.0.0', + major: 27, + minor: 0, + patch: 0, + line: 'v27', + }); + }); + + it('should have correct values for alpha in every release', async () => { + const data = await nodevu({ fetch: mockFetch }); + + deepStrictEqual(data.v27.releases['v27.0.0'].alpha.isAlpha, false); + deepStrictEqual( + data.v27.releases['v27.0.0-alpha.1.0.0'].alpha.isAlpha, + true, + ); + deepStrictEqual( + data.v27.releases['v27.0.0-alpha.0.0.0'].alpha.isAlpha, + true, + ); + deepStrictEqual(data.v26.releases['v26.12.0'].alpha.isAlpha, false); + }); + + it('should have correct files for an Alpha release', async () => { + const data = await nodevu({ fetch: mockFetch }); + const { linux, source } = + data.v27.releases['v27.0.0-alpha.1.0.0'].files.links; + + deepStrictEqual(linux[0].files, [ + 'node-v27.0.0-alpha.1.0.0-linux-x64.tar.gz', + 'node-v27.0.0-alpha.1.0.0-linux-x64.tar.xz', + ]); + deepStrictEqual(source[0].files, ['node-v27.0.0-alpha.1.0.0.tar.xz']); + }); + + it('should have correct support values for a release line with an Alpha phase', async () => { + const data = await nodevu({ fetch: mockFetch }); + + deepStrictEqual(data.v27.support.phases.dates.alpha, '2026-10-28'); + deepStrictEqual(data.v26.support.phases.dates.alpha, undefined); + }); + + it('should have correct dynamic values for support in a release line with an Alpha phase', async () => { + const phases = [ + ['2026-12-15', 'alpha', 'lts'], + ['2027-05-01', 'start', 'lts'], + ['2027-11-01', 'lts', 'maintenance'], + ['2030-05-01', 'end', 'end'], + ]; + + for (const [now, v27, v26] of phases) { + const data = await nodevu({ fetch: mockFetch, now }); + + deepStrictEqual(data.v27.support.phases.current, v27, now); + deepStrictEqual(data.v26.support.phases.current, v26, now); + } + }); +}); From adb331900647496261dd9d350fcfcb096be91049 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:14:36 -0400 Subject: [PATCH 2/4] feat(ranges): add an alpha range and keep alpha releases out of the others Node.js 27 starts shipping vN.0.0-alpha.X.Y.Z releases before a line's first Current release. ranges now skips them everywhere except a new `alpha` range, which lists the alphas of a line in its Alpha phase, so `all.newest` stays the newest stable release. ranges also takes an optional second `options` argument that it passes to @nodevu/core, and depends on the 1.0.0 workspace versions of core and aliases. --- aliases/README.md | 1 + aliases/index.js | 1 + ranges/README.md | 17 +++++- ranges/index.js | 29 ++++++++-- ranges/package.json | 4 +- ranges/test/alpha.test.js | 116 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 ranges/test/alpha.test.js 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..abaf3456 100644 --- a/ranges/package.json +++ b/ranges/package.json @@ -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..e2a14a10 --- /dev/null +++ b/ranges/test/alpha.test.js @@ -0,0 +1,116 @@ +const { deepStrictEqual } = require('node:assert'); +const ranges = require('../index'); +const { describe, it } = require('node:test'); + +// these tests use made-up data that follows the release schedule starting with +// 27.x, 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'), +]; + +// 27.x goes alpha -> start -> lts -> end, with no maintenance phase. the real +// schedule.json lists 27.x'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: '', + }, +}; + +// only serves the releases that would be out by `now`, like the real index.json +function at(now) { + return { + now, + fetch: async (url) => + Response.json( + url.endsWith('schedule.json') + ? schedule + : index.filter((entry) => entry.date <= now), + ), + }; +} + +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', + ]); + }); +}); From 226f025164008a4dc18f689df3cb22514eac518f Mon Sep 17 00:00:00 2001 From: "tierney cyren (bot version)" Date: Tue, 20 Oct 2026 18:09:44 -0400 Subject: [PATCH 3/4] test(ranges): run tests against core's snapshot data at a fixed date (#91) Co-authored-by: botandbang <337765928+botandbang@users.noreply.github.com> --- .github/workflows/tests-ranges.yml | 4 +- ranges/package.json | 4 +- ranges/test/alpha.test.js | 15 ++------ ranges/test/default.test.js | 5 ++- ranges/test/fixtures.js | 31 +++++++++++++++ ranges/test/known.test.js | 60 +++++++++++++++++++++++++++++- ranges/test/matrixAlias.test.js | 34 ++++++++++------- ranges/test/singleAlias.test.js | 13 ++++--- ranges/test/unknownFilter.test.js | 13 ++++--- ranges/test/v.test.js | 5 ++- 10 files changed, 138 insertions(+), 46 deletions(-) create mode 100644 ranges/test/fixtures.js 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/ranges/package.json b/ranges/package.json index abaf3456..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" }, diff --git a/ranges/test/alpha.test.js b/ranges/test/alpha.test.js index e2a14a10..633cdaee 100644 --- a/ranges/test/alpha.test.js +++ b/ranges/test/alpha.test.js @@ -1,5 +1,6 @@ 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 release schedule starting with @@ -41,18 +42,8 @@ const schedule = { }, }; -// only serves the releases that would be out by `now`, like the real index.json -function at(now) { - return { - now, - fetch: async (url) => - Response.json( - url.endsWith('schedule.json') - ? schedule - : index.filter((entry) => entry.date <= now), - ), - }; -} +// 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 () => { 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..b6be339c --- /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. on 2025-09-01, v24 is Current, v22 is Active +// LTS, v20 is Maintenance LTS, and v23 has reached End of Life, so every +// range has versions. every one of those lines also has a security release +// by then, so no security field is empty either. +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..eecd8b3d 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. }); + + // on 2025-09-01, v24 is Current, v22 is Active LTS, v20 is Maintenance LTS, + // and v23 is the newest End of Life line + 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); From da126a317232e718c460203d967bee488851b6a3 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:13:02 -0400 Subject: [PATCH 4/4] test(ranges): describe fixture conditions instead of hardcoding dates and versions in comments --- ranges/test/alpha.test.js | 11 ++++++----- ranges/test/fixtures.js | 8 ++++---- ranges/test/known.test.js | 4 ++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/ranges/test/alpha.test.js b/ranges/test/alpha.test.js index 633cdaee..662aa20a 100644 --- a/ranges/test/alpha.test.js +++ b/ranges/test/alpha.test.js @@ -3,9 +3,9 @@ const ranges = require('../index'); const fixtures = require('./fixtures'); const { describe, it } = require('node:test'); -// these tests use made-up data that follows the release schedule starting with -// 27.x, where every release line gets Alpha releases (versioned as -// `vN.0.0-alpha.X.Y.Z`) before its first Current release. +// 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) { @@ -22,8 +22,9 @@ const index = [ release('v26.12.0', '2026-10-14'), ]; -// 27.x goes alpha -> start -> lts -> end, with no maintenance phase. the real -// schedule.json lists 27.x's lts date as `maintenance` until +// 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: { diff --git a/ranges/test/fixtures.js b/ranges/test/fixtures.js index b6be339c..1b2a0945 100644 --- a/ranges/test/fixtures.js +++ b/ranges/test/fixtures.js @@ -1,10 +1,10 @@ const index = require('../../core/test/data/static/index.json'); const schedule = require('../../core/test/data/static/schedule.json'); -// the default date for tests. on 2025-09-01, v24 is Current, v22 is Active -// LTS, v20 is Maintenance LTS, and v23 has reached End of Life, so every -// range has versions. every one of those lines also has a security release -// by then, so no security field is empty either. +// 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 diff --git a/ranges/test/known.test.js b/ranges/test/known.test.js index eecd8b3d..6c008aec 100644 --- a/ranges/test/known.test.js +++ b/ranges/test/known.test.js @@ -17,8 +17,8 @@ describe('check some values we know should exist', async () => { 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. }); - // on 2025-09-01, v24 is Current, v22 is Active LTS, v20 is Maintenance LTS, - // and v23 is the newest End of Life line + // 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(