From 28a32b8b85eb7c5e67c3693b6a45389ecd012bfc Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 19 Oct 2026 14:32:31 +0200 Subject: [PATCH 1/7] 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/7] 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 e07b9890da4523aae31b548f0d005096e0765fb0 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:35:19 -0400 Subject: [PATCH 3/7] test(ranges): run tests against core's snapshot data at a fixed date the ranges tests fetched the live index.json and schedule.json and used today's clock, so they broke whenever the real schedule left a range empty. on 2026-10-20 no line is in Active LTS, which made lts/latest disappear and failed 5 tests. add test/fixtures.js, which serves core's checked-in snapshots filtered to 2025-09-01 and throws on any other URL. pass it to every ranges call, reuse it in alpha.test.js, add a test that pins each range's newest/oldest/security/lts values on that date, scope the test scripts to *.test.js, and widen the tests-ranges workflow path filter to ranges/** and core's snapshot data. --- .github/workflows/tests-ranges.yml | 3 +- 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, 137 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..45a21284 100644 --- a/.github/workflows/tests-ranges.yml +++ b/.github/workflows/tests-ranges.yml @@ -3,7 +3,8 @@ name: "Test Suite: ranges" on: pull_request: paths: - - 'ranges/*' + - 'ranges/**' + - 'core/test/data/static/**' 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 601a57e8ebfca3ab41eee85e9f8f5bcdac0ee233 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:53:04 -0400 Subject: [PATCH 4/7] fix(ranges): leave empty security and LTS fields undefined instead of "undefined" current, lts/latest and lts/maintenance built their security and LTS fields with template literals, so a line without a security release (or LTS data) got the string "undefined". assign core's values directly instead, and only fill the fields from the first line in each range by checking the versions count, since the old guards relied on the field never being undefined again. --- ranges/README.md | 2 +- ranges/index.js | 45 +++++------- ranges/test/emptyFields.test.js | 126 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 ranges/test/emptyFields.test.js diff --git a/ranges/README.md b/ranges/README.md index da36cb60..fca1a5ab 100644 --- a/ranges/README.md +++ b/ranges/README.md @@ -27,7 +27,7 @@ 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: +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. A field with nothing to point to, like `oldestSecurity` on a release line that hasn't had a security release yet, is `undefined` (and left out of JSON). - all: - versions (Array): An array of versions. Leaves out Alpha releases. diff --git a/ranges/index.js b/ranges/index.js index 8a4de9e6..5062fc6b 100644 --- a/ranges/index.js +++ b/ranges/index.js @@ -150,12 +150,12 @@ async function generateRanges(filter, options) { if (data[line].support?.phases.current === 'start') { ranges.current.versions.push(key); // add current iteration to the versions array if the phase is `start` which means it'll be current. - if ( - ranges.current.oldestSecurity === undefined && - ranges.current.newestSecurity === undefined - ) { - ranges.current.oldestSecurity = `${data[line].security.oldest}`; - ranges.current.newestSecurity = `${data[line].security.newest}`; + // only the first Current line fills these in. checking the versions + // count rather than the fields, since a line without a security + // release leaves them undefined. + if (ranges.current.versions.length === 1) { + ranges.current.oldestSecurity = data[line].security.oldest; + ranges.current.newestSecurity = data[line].security.newest; } } } @@ -176,14 +176,12 @@ async function generateRanges(filter, options) { // TODO: do we want to include all versions in the release line, even prior to it being minted LTS? ranges['lts/latest'].versions.push(key); - if (ranges['lts/latest'].oldestSecurity === undefined) { - // only checking if this one is undefined, since everything else _should_ be undefined if this one is. saves some processing power. - ranges['lts/latest'].oldestSecurity = - `${data[line].security.oldest}`; - ranges['lts/latest'].newestSecurity = - `${data[line].security.newest}`; - ranges['lts/latest'].oldestLts = `${data[line].support.lts.oldest}`; - ranges['lts/latest'].newestLts = `${data[line].support.lts.newest}`; + if (ranges['lts/latest'].versions.length === 1) { + // only the first LTS line fills these in, same as 'current' + ranges['lts/latest'].oldestSecurity = data[line].security.oldest; + ranges['lts/latest'].newestSecurity = data[line].security.newest; + ranges['lts/latest'].oldestLts = data[line].support.lts.oldest; + ranges['lts/latest'].newestLts = data[line].support.lts.newest; } } } @@ -203,26 +201,21 @@ async function generateRanges(filter, options) { ) { ranges['lts/maintenance'].versions.push(key); - if ( - ranges['lts/maintenance'].newestSecurity === undefined && - ranges['lts/maintenance'].newestLts === undefined - ) { + if (ranges['lts/maintenance'].versions.length === 1) { ranges['lts/maintenance'].newestSecurity = - `${data[line].security.newest}`; - ranges['lts/maintenance'].newestLts = - `${data[line].support.lts.newest}`; + data[line].security.newest; + ranges['lts/maintenance'].newestLts = data[line].support.lts.newest; } if ( ranges['lts/maintenance'].oldestSecurity !== - `${data[line].security.oldest}` || + data[line].security.oldest || ranges['lts/maintenance'].oldestLts !== - `${data[line].support.lts.oldest}` + data[line].support.lts.oldest ) { ranges['lts/maintenance'].oldestSecurity = - `${data[line].security.oldest}`; - ranges['lts/maintenance'].oldestLts = - `${data[line].support.lts.oldest}`; + data[line].security.oldest; + ranges['lts/maintenance'].oldestLts = data[line].support.lts.oldest; } } } diff --git a/ranges/test/emptyFields.test.js b/ranges/test/emptyFields.test.js new file mode 100644 index 00000000..ed2a8499 --- /dev/null +++ b/ranges/test/emptyFields.test.js @@ -0,0 +1,126 @@ +const { deepStrictEqual } = require('node:assert'); +const ranges = require('../index'); +const { at } = require('./fixtures'); +const { describe, it } = require('node:test'); + +// core's snapshot time. 25.x is Current and hasn't had a security release yet. +const snapshotNow = JSON.parse(require('../../core/test/data/static/now.json')); + +describe('fields without a value should be undefined, not "undefined"', async () => { + it('should leave out the security fields of a Current line without security releases', async () => { + const data = await ranges(undefined, at(snapshotNow)); + + deepStrictEqual(data.current.versions, ['v25.1.0', 'v25.0.0']); + deepStrictEqual(data.current.oldestSecurity, undefined); + deepStrictEqual(data.current.newestSecurity, undefined); + deepStrictEqual(JSON.parse(JSON.stringify(data.current)), { + versions: ['v25.1.0', 'v25.0.0'], + newest: 'v25.1.0', + oldest: 'v25.0.0', + }); + }); + + it('should leave out the security fields of an LTS line without security releases', async () => { + // on 2016-06-01, 4.x is the only Active LTS line and none of its + // releases are marked as security releases + const data = await ranges(undefined, at('2016-06-01')); + + deepStrictEqual(data['lts/latest'].oldestSecurity, undefined); + deepStrictEqual(data['lts/latest'].newestSecurity, undefined); + deepStrictEqual(data['lts/latest'].oldestLts, '4.2.0'); + deepStrictEqual(data['lts/latest'].newestLts, '4.4.5'); + deepStrictEqual(data['lts/active'].newestSecurity, undefined); + }); + + it('should only take the security fields from the first Current line', async () => { + // on 2016-06-23, 6.x has no security releases, while 0.12.15 (also in + // "current", since 0.12.x has no LTS or maintenance dates) is one + const data = await ranges('current', at('2016-06-23')); + + deepStrictEqual(data.current.newest, 'v6.2.2'); + deepStrictEqual(data.current.versions.includes('v0.12.15'), true); + deepStrictEqual(data.current.oldestSecurity, undefined); + deepStrictEqual(data.current.newestSecurity, undefined); + }); + + it('should leave out the LTS and security fields when the data has none', async () => { + // made-up data, since the real data always has these by the time a line + // is LTS: neither line has security releases, and the newest release of + // each line isn't marked as LTS, so core has no `support.lts.newest` + const release = (version, date, lts = false) => ({ + version, + date, + lts, + files: ['linux-x64', 'src'], + }); + const index = [ + release('v99.1.0', '2099-11-01'), + release('v99.0.0', '2099-10-25', 'Placeholder'), + release('v98.1.0', '2098-11-01'), + release('v98.0.0', '2098-10-25', 'Placeholder'), + ]; + const schedule = { + v98: { + start: '2098-04-20', + lts: '2098-10-20', + maintenance: '2099-10-20', + end: '2101-04-30', + codename: '', + }, + v99: { + start: '2099-04-20', + lts: '2099-10-20', + maintenance: '2100-10-20', + end: '2102-04-30', + codename: '', + }, + }; + // round-tripping through JSON to check what ends up in the output + const data = JSON.parse( + JSON.stringify( + await ranges(undefined, at('2099-12-01', { index, schedule })), + ), + ); + + deepStrictEqual(data['lts/latest'], { + versions: ['v99.0.0'], + oldestLts: '99.0.0', + newest: 'v99.0.0', + oldest: 'v99.0.0', + }); + deepStrictEqual(data['lts/maintenance'], { + versions: ['v98.0.0'], + oldestLts: '98.0.0', + newest: 'v98.0.0', + oldest: 'v98.0.0', + }); + deepStrictEqual(data['lts/active'], { + versions: ['v99.0.0', 'v98.0.0'], + oldestLts: '98.0.0', + newest: 'v99.0.0', + oldest: 'v98.0.0', + }); + }); + + it('should never have "undefined" as a value', async () => { + for (const now of [ + '2016-06-01', + '2018-01-01', + '2020-01-01', + '2024-01-01', + '2025-09-01', + snapshotNow, + ]) { + const data = await ranges(undefined, at(now)); + for (const range in data) { + for (const field in data[range]) { + deepStrictEqual( + data[range][field] === 'undefined', + false, + `${now} ${range}.${field}`, + ); + } + } + } + }); +}); 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 5/7] 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 6/7] 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( From da25312f5db3402d4b173427b3dd197811e85068 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:13:15 -0400 Subject: [PATCH 7/7] test(ranges): describe empty-field cases without hardcoding dates and versions in comments --- ranges/test/emptyFields.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ranges/test/emptyFields.test.js b/ranges/test/emptyFields.test.js index ed2a8499..582295e0 100644 --- a/ranges/test/emptyFields.test.js +++ b/ranges/test/emptyFields.test.js @@ -3,7 +3,7 @@ const ranges = require('../index'); const { at } = require('./fixtures'); const { describe, it } = require('node:test'); -// core's snapshot time. 25.x is Current and hasn't had a security release yet. +// core's snapshot time, when the Current line hasn't had a security release yet const snapshotNow = JSON.parse(require('../../core/test/data/static/now.json')); describe('fields without a value should be undefined, not "undefined"', async () => { @@ -21,8 +21,7 @@ describe('fields without a value should be undefined, not "undefined"', async () }); it('should leave out the security fields of an LTS line without security releases', async () => { - // on 2016-06-01, 4.x is the only Active LTS line and none of its - // releases are marked as security releases + // a date when the only Active LTS line hasn't had a security release const data = await ranges(undefined, at('2016-06-01')); deepStrictEqual(data['lts/latest'].oldestSecurity, undefined); @@ -33,8 +32,9 @@ describe('fields without a value should be undefined, not "undefined"', async () }); it('should only take the security fields from the first Current line', async () => { - // on 2016-06-23, 6.x has no security releases, while 0.12.15 (also in - // "current", since 0.12.x has no LTS or maintenance dates) is one + // a date when the newest Current line has no security releases, but an + // older line also in "current" (one with no LTS or maintenance dates in + // schedule.json) has one const data = await ranges('current', at('2016-06-23')); deepStrictEqual(data.current.newest, 'v6.2.2');