From c7aec4447793b07e14ddfe14d3f827f02407cea9 Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:52:56 -0500 Subject: [PATCH] test: property-test generated index and schedule data, now, file ids, and core options --- core/test/generated-index.js | 184 ++++++++++++++++++++++++++ core/test/generated-schedule.js | 171 ++++++++++++++++++++++++ core/test/parseOptions.js | 47 +++++++ parsefiles/test/parseFilename.test.js | 45 +++++++ 4 files changed, 447 insertions(+) create mode 100644 core/test/generated-index.js create mode 100644 core/test/generated-schedule.js diff --git a/core/test/generated-index.js b/core/test/generated-index.js new file mode 100644 index 0000000..df439ed --- /dev/null +++ b/core/test/generated-index.js @@ -0,0 +1,184 @@ +const assert = require('node:assert'); +const { describe, it } = require('node:test'); +const fc = require('fast-check'); +const semver = require('semver'); +const nodevu = require('../index'); +const { at } = require('@nodevu/fixtures'); +const snapshot = require('@nodevu/fixtures/data/index.json'); + +// every generated release is out by then +const now = '2027-01-01'; + +describe('core on a generated index.json', async () => { + it('should keep every release as the index lists it, in any order', async () => { + await fc.assert( + fc.asyncProperty(index(), async (entries) => { + // Arrange + const options = at(now, { index: entries, schedule: {} }); + + // Act + const data = await nodevu(options); + + // Assert + const keys = Object.values(data).flatMap(({ releases }) => + Object.keys(releases), + ); + + assert.strictEqual(keys.length, entries.length); + + for (const entry of entries) { + const { line, key } = place(entry.version); + const release = data[line].releases[key]; + + const linked = Object.values(release.files.links).flatMap((links) => + links.map(({ id }) => id), + ); + + assert.strictEqual(release.semver.line, line); + assert.strictEqual(release.releaseDate, entry.date); + assert.strictEqual(release.lts.isLts, Boolean(entry.lts)); + assert.strictEqual(release.security.isSecurity, entry.security); + assert.strictEqual( + release.alpha.isAlpha, + entry.version.includes('-alpha.'), + ); + assert.deepStrictEqual(release.files.available, entry.files ?? []); + assert.deepStrictEqual( + linked.sort(), + [...(entry.files ?? [])].sort(), + ); + } + + for (const [line, { releases, security }] of Object.entries(data)) { + const flagged = Object.values(releases) + .filter((release) => release.security.isSecurity) + .map((release) => release.semver.raw); + + assert.deepStrictEqual( + [...security.all].sort(), + flagged.sort(), + line, + ); + } + }), + ); + }); + + it('should order security and LTS releases by version on a newest-first index', async () => { + await fc.assert( + fc.asyncProperty(index(), async (shuffled) => { + // Arrange + // index.json lists releases newest first, and core relies on it + const entries = [...shuffled].sort((a, b) => + semver.rcompare(place(a.version).raw, place(b.version).raw), + ); + + const lines = new Set( + entries.map((entry) => place(entry.version).line), + ); + + const schedule = Object.fromEntries( + [...lines].map((line) => [line, { start: '2000-01-01' }]), + ); + + // Act + const data = await nodevu(at(now, { index: entries, schedule })); + + // Assert + for (const [line, { releases, security, support }] of Object.entries( + data, + )) { + const all = Object.values(releases).map(({ semver }) => semver.raw); + + const lts = Object.values(releases) + .filter((release) => release.lts.isLts) + .map((release) => release.semver.raw) + .sort(semver.rcompare); + + assert.deepStrictEqual( + security.all, + [...security.all].sort(semver.rcompare), + line, + ); + assert.strictEqual(security.newest, security.all[0], line); + assert.strictEqual(security.oldest, security.all.at(-1), line); + assert.strictEqual(support?.lts?.oldest, lts.at(-1), line); + + // newest is only set when the line's newest release is LTS, which + // is always true of index.json, since a line stays LTS once it is + const isNewestLts = releases[`v${all[0]}`].lts.isLts; + assert.strictEqual( + support?.lts?.newest, + isNewestLts ? lts[0] : undefined, + line, + ); + } + }), + ); + }); +}); + +// Helpers + +/** + * Where core files a version: its release line and its key in that line. + * + * @param {string} version + */ +function place(version) { + const parsed = semver.coerce(version, { includePrerelease: true }); + assert.ok(parsed); + + return { + raw: parsed.version, + key: `v${parsed.version}`, + line: parsed.major === 0 ? `v0.${parsed.minor}` : `v${parsed.major}`, + }; +} + +// releases unique by version, in any order, with odd lts values, unknown file +// ids and missing files +function index() { + const ids = [...new Set(snapshot.flatMap((release) => release.files))]; + + return fc.uniqueArray( + fc.record( + { + version: version(), + date: fc + .date({ + min: new Date('2009-06-01'), + max: new Date('2026-12-31'), + noInvalidDate: true, + }) + .map((date) => date.toISOString().slice(0, 10)), + lts: fc.oneof(fc.constant(false), fc.constant(true), fc.string()), + security: fc.boolean(), + files: fc.array(fc.oneof(fc.constantFrom(...ids), fc.string()), { + maxLength: 5, + }), + }, + { requiredKeys: ['version', 'date', 'lts', 'security'] }, + ), + { selector: (entry) => place(entry.version).key, maxLength: 20 }, + ); +} + +// a version as index.json writes it, including 0.x lines and Alpha releases +function version() { + return fc.oneof( + fc + .tuple(fc.nat({ max: 12 }), fc.nat({ max: 5 })) + .map(([minor, patch]) => `v0.${minor}.${patch}`), + fc + .tuple( + fc.integer({ min: 1, max: 30 }), + fc.nat({ max: 5 }), + fc.nat({ max: 5 }), + ) + .map(([major, minor, patch]) => `v${major}.${minor}.${patch}`), + fc + .tuple(fc.integer({ min: 1, max: 30 }), fc.nat({ max: 5 })) + .map(([major, alpha]) => `v${major}.0.0-alpha.${alpha}.0.0`), + ); +} diff --git a/core/test/generated-schedule.js b/core/test/generated-schedule.js new file mode 100644 index 0000000..1618d86 --- /dev/null +++ b/core/test/generated-schedule.js @@ -0,0 +1,171 @@ +const assert = require('node:assert'); +const { describe, it } = require('node:test'); +const fc = require('fast-check'); +const { DateTime } = require('luxon'); +const nodevu = require('../index'); +const { at } = require('@nodevu/fixtures'); +const snapshot = require('@nodevu/fixtures/data/schedule.json'); + +const phases = /** @type {const} */ ([ + 'alpha', + 'start', + 'lts', + 'maintenance', + 'end', +]); + +const lines = ['v20', 'v21', 'v22']; + +// one release per line +const index = lines.map((line) => ({ + version: `${line}.0.0`, + date: '2000-01-01', + files: ['src'], +})); + +describe('core on a generated schedule.json', async () => { + it('should be in the latest phase that has begun, whatever the schedule holds', async () => { + await fc.assert( + fc.asyncProperty(schedule(), date(), async (generated, now) => { + // Arrange + const options = at('2027-01-01', { index, schedule: generated }); + + // Act + const data = await nodevu({ ...options, now }); + + // Assert + for (const line of lines) { + const support = data[line].support; + + if (generated[line]?.start === undefined) { + assert.strictEqual(support, undefined, line); + continue; + } + + assert.ok(support, line); + + const begun = phases.filter((phase) => { + const start = DateTime.fromISO(support.phases.dates[phase] ?? ''); + + return start.isValid && start <= DateTime.fromISO(now); + }); + + assert.deepStrictEqual( + support.phases.current, + begun.at(-1) ?? {}, + `${line}: ${JSON.stringify(generated[line])} on ${now}`, + ); + assert.strictEqual(support.codename, generated[line].codename); + + for (const phase of phases) { + assert.strictEqual( + support.phases.dates[phase], + generated[line][phase], + line, + ); + } + } + }), + ); + }); + + it('should give the same phases for one moment written in any zone', async () => { + // the days a phase starts on, where a moment read in the wrong zone + // lands in the wrong phase + const starts = Object.values(snapshot) + .flatMap((line) => Object.values(line)) + .filter( + (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && value <= '2026-10-01', + ); + + await fc.assert( + fc.asyncProperty( + fc.oneof(day(), fc.constantFrom(...starts)), + zone(), + async (now, zone) => { + // Arrange + const zoned = DateTime.fromISO(now).setZone(zone); + const options = at(now); + + // Act + const results = await Promise.all( + [now, zoned, String(zoned.toISO())].map((written) => + nodevu({ ...options, now: written }), + ), + ); + + // Assert + const [expected, ...others] = results.map((data) => + Object.fromEntries( + Object.entries(data).map(([line, { support }]) => [ + line, + support?.phases.current, + ]), + ), + ); + + for (const other of others) { + assert.deepStrictEqual(other, expected); + } + }, + ), + ); + }); +}); + +// Helpers + +// a day, as YYYY-MM-DD +function day() { + return fc + .date({ + min: new Date('2009-06-01'), + max: new Date('2026-10-01'), + noInvalidDate: true, + }) + .map((date) => date.toISOString().slice(0, 10)); +} + +// a day, or a moment with a time and an offset +function date() { + return fc.oneof( + day(), + fc + .tuple(day(), fc.integer({ min: 0, max: 86_399 }), zone()) + .map(([on, seconds, zone]) => + String(DateTime.fromISO(on, { zone }).plus({ seconds }).toISO()), + ), + ); +} + +function zone() { + return fc.constantFrom( + 'UTC', + 'America/New_York', + 'Asia/Kolkata', + 'Pacific/Chatham', + 'UTC+14', + 'UTC-12', + ); +} + +// a schedule.json for each line, with phases missing, out of order, written +// with times and offsets, or unreadable +function schedule() { + const phaseDate = fc.oneof(date(), fc.string()); + + return fc.dictionary( + fc.constantFrom(...lines), + fc.record( + { + alpha: phaseDate, + start: phaseDate, + lts: phaseDate, + maintenance: phaseDate, + end: phaseDate, + codename: fc.string(), + }, + { requiredKeys: [] }, + ), + ); +} diff --git a/core/test/parseOptions.js b/core/test/parseOptions.js index 04ddc56..2ccbd06 100644 --- a/core/test/parseOptions.js +++ b/core/test/parseOptions.js @@ -1,9 +1,12 @@ const { deepStrictEqual, ok } = require('node:assert'); const { describe, it } = require('node:test'); +const fc = require('fast-check'); const { fetch: undiciFetch } = require('undici'); const { DateTime } = require('luxon'); const parseOptions = require('../util/prod/optionsParser'); +const defaults = parseOptions(); + describe('the parseOptions module should return all correct defaults', async () => { it('should return the default date', async () => { const now = DateTime.now(); @@ -65,3 +68,47 @@ describe('the parseOptions module should still work when defaults are changed', deepStrictEqual(defaultParsedOptions.urls.schedule, 'https://example.com'); }); }); + +describe('the parseOptions module should keep every option it is given', async () => { + it('should take each option that is set, and the default for the rest', async () => { + // an empty string counts as unset, so it falls back too + const option = (/** @type {fc.Arbitrary} */ value) => + fc.option(fc.oneof(value, fc.constant('')), { nil: undefined }); + + await fc.assert( + fc.property( + fc.record( + { + fetch: option(fc.func(fc.anything())), + now: option(fc.oneof(fc.string(), fc.constant(DateTime.now()))), + urls: fc.option( + fc.record( + { index: option(fc.webUrl()), schedule: option(fc.webUrl()) }, + { requiredKeys: [] }, + ), + { nil: undefined }, + ), + }, + { requiredKeys: [] }, + ), + (options) => { + const parsed = parseOptions( + /** @type {import('../index').Options} */ (options), + ); + + deepStrictEqual(parsed.fetch, options.fetch || defaults.fetch); + deepStrictEqual(parsed.urls, { + index: options.urls?.index || defaults.urls.index, + schedule: options.urls?.schedule || defaults.urls.schedule, + }); + + if (options.now) { + deepStrictEqual(parsed.now, options.now); + } else { + ok(parsed.now instanceof DateTime); + } + }, + ), + ); + }); +}); diff --git a/parsefiles/test/parseFilename.test.js b/parsefiles/test/parseFilename.test.js index fdd2567..71a6ae6 100644 --- a/parsefiles/test/parseFilename.test.js +++ b/parsefiles/test/parseFilename.test.js @@ -124,6 +124,51 @@ describe('parseFilename', () => { ), ); }); + + it('should keep the shape of its result for any id, known or not', async () => { + await fc.assert( + fc.property( + fc.oneof(realIdArbitrary(), fc.string()), + versionArbitrary(), + (id, version) => { + // Arrange + const types = [ + 'aix', + 'headers', + 'linux', + 'macos', + 'source', + 'sunos', + 'windows', + undefined, + ]; + + const architectures = [ + 'x86', + 'x64', + 'arm64', + 'armv7l', + 'armv6l', + 'power', + 'z', + undefined, + ]; + + // Act + const result = parseFilename(id, version); + + // Assert + assert.strictEqual(result.id, id); + assert.ok(result.files.length > 0); + assert.ok(types.includes(result.type), String(result.type)); + assert.ok( + architectures.includes(result.architecture), + String(result.architecture), + ); + }, + ), + ); + }); }); // Helpers