diff --git a/core/test/properties.js b/core/test/properties.js new file mode 100644 index 0000000..535c622 --- /dev/null +++ b/core/test/properties.js @@ -0,0 +1,122 @@ +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('../util/dev/fixtures'); + +const phases = ['alpha', 'start', 'lts', 'maintenance', 'end']; + +describe('core', async () => { + it('should key every release by its raw version, under its own line', async () => { + await fc.assert( + fc.asyncProperty(day(), async (now) => { + // Arrange + const options = at(now); + + // Act + const data = await nodevu(options); + + // Assert + for (const [line, { releases }] of Object.entries(data)) { + for (const [key, release] of Object.entries(releases)) { + assert.strictEqual(release.semver.line, line); + assert.strictEqual(key, `v${release.semver.raw}`); + } + } + }), + ); + }); + + it('should list every security release newest first, bounded by newest and oldest', async () => { + await fc.assert( + fc.asyncProperty(day(), async (now) => { + // Arrange + const options = at(now); + + // Act + const data = await nodevu(options); + + // Assert + for (const { releases, security } of Object.values(data)) { + const flagged = Object.values(releases) + .filter((release) => release.security.isSecurity) + .map((release) => release.semver.raw); + + assert.deepStrictEqual( + security.all, + [...security.all].sort(semver.rcompare), + ); + assert.strictEqual(security.newest, security.all[0]); + assert.strictEqual(security.oldest, security.all.at(-1)); + assert.deepStrictEqual([...security.all].sort(), flagged.sort()); + } + }), + ); + }); + + it('should bound support.lts by two LTS releases, newest >= oldest', async () => { + await fc.assert( + fc.asyncProperty(day(), async (now) => { + // Arrange + const options = at(now); + + // Act + const data = await nodevu(options); + + // Assert + for (const { releases, support } of Object.values(data)) { + const newest = support?.lts?.newest; + const oldest = support?.lts?.oldest; + + if (newest === undefined || oldest === undefined) { + continue; + } + + assert.ok(semver.gte(newest, oldest), `${newest} < ${oldest}`); + assert.strictEqual(releases[`v${newest}`].lts.isLts, true); + assert.strictEqual(releases[`v${oldest}`].lts.isLts, true); + } + }), + ); + }); + + it('should only ever move a line forward through its phases', async () => { + await fc.assert( + fc.asyncProperty(day(), day(), async (a, b) => { + // Arrange + const [earlier, later] = [a, b].sort(); + + // Act + const before = await nodevu(at(earlier)); + const after = await nodevu(at(later)); + + // Assert + for (const [line, { support }] of Object.entries(before)) { + assert.ok( + phaseIndex(after[line].support) >= phaseIndex(support), + `${line} went from ${support?.phases.current} to ${after[line].support?.phases.current}`, + ); + } + }), + ); + }); +}); + +// Helpers + +// a day anywhere in the fixture's span, 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 phase that hasn't begun (`{}`) sorts before every phase +function phaseIndex(support) { + return phases.indexOf(String(support?.phases.current)); +} diff --git a/opt/package.json b/opt/package.json index fb79db3..3805f86 100644 --- a/opt/package.json +++ b/opt/package.json @@ -32,6 +32,7 @@ "homepage": "https://github.com/cutenode/nodevu#readme", "devDependencies": { "c8": "^12.0.0", + "fast-check": "^4.10.2", "luxon": "^3.5.0", "undici": "^8.12.2" }, diff --git a/opt/test/test.js b/opt/test/test.js index 5397789..be0a4ff 100644 --- a/opt/test/test.js +++ b/opt/test/test.js @@ -1,5 +1,6 @@ -const { deepStrictEqual } = require('node:assert'); +const { deepStrictEqual, ok, strictEqual } = require('node:assert'); const { describe, it } = require('node:test'); +const fc = require('fast-check'); const { fetch: undiciFetch } = require('undici'); const { DateTime } = require('luxon'); const opt = require('../index'); @@ -73,3 +74,50 @@ describe('the parseOptions module should still work when defaults are changed', deepStrictEqual(defaultParsedOptions.urls.schedule, 'https://example.com'); }); }); + +describe('opt', async () => { + it('should use each option passed and the default for each one left out', async () => { + await fc.assert( + fc.property(options(), (input) => { + // Arrange + const defaults = opt(); + + // Act + const result = opt(input); + + // Assert + strictEqual(result.fetch, input.fetch ?? defaults.fetch); + strictEqual( + result.urls.index, + input.urls?.index ?? defaults.urls.index, + ); + strictEqual( + result.urls.schedule, + input.urls?.schedule ?? defaults.urls.schedule, + ); + + if (input.now === undefined) { + ok(DateTime.isDateTime(result.now)); + } else { + strictEqual(result.now, input.now); + } + }), + ); + }); +}); + +// Helpers + +// any subset of the options opt takes, each one set to a usable value +function options() { + const value = fc.string({ minLength: 1 }); + + return fc.record( + { + fetch: fc.func(fc.anything()), + now: value, + urls: fc.record({ index: value, schedule: value }, { requiredKeys: [] }), + }, + { requiredKeys: [] }, + ); +} diff --git a/parsefiles/package.json b/parsefiles/package.json index 198aaf6..882c428 100644 --- a/parsefiles/package.json +++ b/parsefiles/package.json @@ -25,7 +25,8 @@ "author": "Tierney Cyren ", "license": "MIT", "devDependencies": { - "c8": "^12.0.0" + "c8": "^12.0.0", + "fast-check": "^4.10.2" }, "repository": { "type": "git", diff --git a/parsefiles/test/parseFilename.test.js b/parsefiles/test/parseFilename.test.js index 1720b3b..cb2838c 100644 --- a/parsefiles/test/parseFilename.test.js +++ b/parsefiles/test/parseFilename.test.js @@ -1,7 +1,9 @@ const assert = require('node:assert'); const { describe, it } = require('node:test'); +const fc = require('fast-check'); const parseFilename = require('../index'); +const releases = require('../../core/test/data/static/index.json'); describe('parseFilename', () => { it('should only list the win-x86 directory for win-x86-exe', () => { @@ -46,4 +48,98 @@ describe('parseFilename', () => { // Assert assert.deepStrictEqual(result.files, ['node-v4.0.0-darwin-x86.tar.gz']); }); + + it('should always set a type for a real id', async () => { + await fc.assert( + fc.property(realIdArbitrary(), versionArbitrary(), (id, version) => { + // Arrange (by the arbitraries) + + // Act + const result = parseFilename(id, version); + + // Assert + assert.notStrictEqual(result.type, undefined); + }), + ); + }); + + it('should always keep the id it was given', async () => { + await fc.assert( + fc.property(realIdArbitrary(), versionArbitrary(), (id, version) => { + // Arrange (by the arbitraries) + + // Act + const result = parseFilename(id, version); + + // Assert + assert.strictEqual(result.id, id); + }), + ); + }); + + it('should never return the bare id as a file name', async () => { + await fc.assert( + fc.property(realIdArbitrary(), versionArbitrary(), (id, version) => { + // Arrange (by the arbitraries) + + // Act + const result = parseFilename(id, version); + + // Assert + assert.ok(!result.files.includes(id), result.files.join(', ')); + }), + ); + }); + + it('should always name files after the version or a win-* directory', async () => { + await fc.assert( + fc.property(realIdArbitrary(), versionArbitrary(), (id, version) => { + // Arrange + const isNamed = (/** @type {string} */ name) => + name.includes(`v${version}`) || /^win-[^/]+\/[^/]+$/.test(name); + + // Act + const result = parseFilename(id, version); + + // Assert + assert.ok(result.files.every(isNamed), result.files.join(', ')); + }), + ); + }); + + it('should always set an architecture for a real id other than src and headers', async () => { + await fc.assert( + fc.property( + realIdArbitrary(['src', 'headers']), + versionArbitrary(), + (id, version) => { + // Arrange (by the arbitraries) + + // Act + const result = parseFilename(id, version); + + // Assert + assert.notStrictEqual(result.architecture, undefined); + }, + ), + ); + }); }); + +// Helpers + +/** + * Every id in the `files` of a release in the release index snapshot. + * + * @param {string[]} [excluded] Ids to leave out. + */ +function realIdArbitrary(excluded = []) { + const ids = new Set(releases.flatMap((release) => release.files)); + + return fc.constantFrom(...[...ids].filter((id) => !excluded.includes(id))); +} + +/** A version without its `v`, such as `'17.7.0'`. */ +function versionArbitrary() { + return fc.tuple(fc.nat(), fc.nat(), fc.nat()).map((parts) => parts.join('.')); +} diff --git a/ranges/package.json b/ranges/package.json index d744921..7694cbf 100644 --- a/ranges/package.json +++ b/ranges/package.json @@ -31,7 +31,8 @@ "@nodevu/core": "^1.0.0" }, "devDependencies": { - "c8": "^12.0.0" + "c8": "^12.0.0", + "fast-check": "^4.10.2" }, "types": "types/index.d.ts" } diff --git a/ranges/test/properties.test.js b/ranges/test/properties.test.js new file mode 100644 index 0000000..95ade92 --- /dev/null +++ b/ranges/test/properties.test.js @@ -0,0 +1,207 @@ +const { deepStrictEqual, match } = require('node:assert'); +const aliases = require('@nodevu/aliases'); +const fc = require('fast-check'); +const ranges = require('../index'); +const { at } = require('./fixtures'); +const index = require('../../core/test/data/static/index.json'); +const schedule = require('../../core/test/data/static/schedule.json'); +const { describe, it } = require('node:test'); + +// an LTS bound from @nodevu/core, a version without a `v`, like 20.9.0 +const ltsVersion = /^\d+\.\d+\.\d+$/; + +describe('generateRanges', () => { + it('should give the same ranges for any filter as it does for "all"', async () => { + await fc.assert( + fc.asyncProperty( + day(), + // a filter with "all" in it is the same as "all", so it'd check nothing + fc.subarray( + aliases.filter((alias) => alias !== 'all'), + { minLength: 1 }, + ), + async (now, filter) => { + // Arrange + const everything = await ranges('all', at(now)); + + // Act + const data = await ranges(filter, at(now)); + + // Assert + for (const alias in data) { + deepStrictEqual(data[alias], everything[alias], alias); + } + }, + ), + { numRuns: 50 }, + ); + }); + + it('should only give the requested ranges, unless "all" is requested', async () => { + await fc.assert( + fc.asyncProperty( + day(), + fc.subarray(aliases, { minLength: 1 }), + async (now, filter) => { + // Act + const data = await ranges(filter, at(now)); + + // Assert + const unrequested = Object.keys(data).filter( + (alias) => !filter.includes(alias), + ); + + deepStrictEqual(filter.includes('all') ? [] : unrequested, []); + }, + ), + { numRuns: 50 }, + ); + }); + + it('should give every range a newest and oldest from its versions, and never empty versions', async () => { + await fc.assert( + fc.asyncProperty(day(), async (now) => { + // Act + const data = await ranges('all', at(now)); + + // Assert + for (const [alias, range] of Object.entries(data)) { + deepStrictEqual(range.versions.length > 0, true, alias); + deepStrictEqual(range.newest, range.versions[0], alias); + deepStrictEqual(range.oldest, range.versions.at(-1), alias); + } + }), + { numRuns: 50 }, + ); + }); + + it('should make "lts/active" the versions of "lts/latest" then "lts/maintenance"', async () => { + await fc.assert( + fc.asyncProperty(day(), async (now) => { + // Act + const data = await ranges('all', at(now)); + + // Assert + const versions = versionsOf(data['lts/latest']).concat( + versionsOf(data['lts/maintenance']), + ); + + deepStrictEqual(versionsOf(data['lts/active']), versions); + }), + { numRuns: 50 }, + ); + }); + + it('should always give "lts/active" its LTS bounds', async () => { + await fc.assert( + fc.asyncProperty(day(), async (now) => { + // Act + const active = (await ranges('lts/active', at(now)))['lts/active']; + + // Assert + // every release in lts/active is an LTS release, so its lines always + // have LTS bounds + if (active) { + match(active.newestLts, ltsVersion); + match(active.oldestLts, ltsVersion); + } + }), + { numRuns: 50 }, + ); + }); + + it('should only put versions from "all" in the other ranges', async () => { + await fc.assert( + fc.asyncProperty(day(), async (now) => { + // Act + const data = await ranges('all', at(now)); + + // Assert + const all = new Set(versionsOf(data.all)); + + for (const alias of [ + 'current', + 'lts/latest', + 'lts/active', + 'lts/maintenance', + 'eol', + ]) { + const outside = versionsOf(data[alias]).filter( + (version) => !all.has(version), + ); + + deepStrictEqual(outside, [], alias); + } + }), + { numRuns: 50 }, + ); + }); + + it('should never put an Alpha release in "all"', async () => { + await fc.assert( + fc.asyncProperty( + day(), + fc.integer({ min: 26 }), + fc.uniqueArray(fc.nat(), { minLength: 1 }), + async (now, major, alphas) => { + // Arrange + // the snapshot has no Alpha releases, so this adds a line in its + // Alpha phase, newer than any in the snapshot + const options = at(now, withAlphaLine(now, major, alphas)); + + // Act + const data = await ranges('all', options); + + // Assert + deepStrictEqual(data.alpha.versions.length, alphas.length); + deepStrictEqual( + versionsOf(data.all).filter((version) => + version.includes('-alpha.'), + ), + [], + ); + }, + ), + { numRuns: 50 }, + ); + }); +}); + +// Helpers + +// any day from before the snapshot's first release to after its newest, +// 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)); +} + +// the snapshot's index.json and schedule.json, plus Alpha releases of +// `vMAJOR`, released on `now` and still in the Alpha phase +function withAlphaLine(now, major, alphas) { + const releases = alphas.map((alpha) => ({ + version: `v${major}.0.0-alpha.${alpha}.0.0`, + date: now, + lts: false, + security: false, + files: ['linux-x64', 'src'], + })); + + return { + index: [...releases, ...index], + schedule: { + ...schedule, + [`v${major}`]: { alpha: now, start: '9999-01-01', end: '9999-12-31' }, + }, + }; +} + +// a range's versions, or none when it's left out +function versionsOf(range) { + return range?.versions ?? []; +} diff --git a/translate/package.json b/translate/package.json index d34a315..b13f588 100644 --- a/translate/package.json +++ b/translate/package.json @@ -29,8 +29,10 @@ "updates:update": "npx npm-check-updates -u" }, "devDependencies": { + "@nodevu/aliases": "^1.1.0", "@nodevu/ranges": "^1.0.0", - "c8": "^12.0.0" + "c8": "^12.0.0", + "fast-check": "^4.10.2" }, "engines": { "node": ">=18.0.0" diff --git a/translate/test/translate.test.js b/translate/test/translate.test.js index 5eb8a7d..de24677 100644 --- a/translate/test/translate.test.js +++ b/translate/test/translate.test.js @@ -1,8 +1,12 @@ const assert = require('node:assert'); const { describe, it } = require('node:test'); +const fc = require('fast-check'); +const aliases = require('@nodevu/aliases'); const translate = require('../index'); +const terms = ['current', 'lts_latest', 'lts', 'supported', 'all']; + describe('translate', () => { it('should reject a name inherited from Object.prototype', async () => { // Arrange @@ -25,4 +29,47 @@ describe('translate', () => { // Assert assert.deepStrictEqual(result, ['lts/active', 'lts/maintenance']); }); + + it("should never resolve a value that isn't a Package Support term", async () => { + await fc.assert( + fc.asyncProperty(notTermArbitrary(), async (value) => { + // Arrange (by the arbitrary) + + // Act + const result = translate(value); + + // Assert + await assert.rejects(result, { message: `Unknown value: ${value}` }); + }), + ); + }); + + it('should always resolve a term to aliases from @nodevu/aliases', async () => { + await fc.assert( + fc.asyncProperty(fc.constantFrom(...terms), async (term) => { + // Arrange (by the arbitrary) + + // Act + const result = await translate(term); + + // Assert + assert.ok( + result.every((alias) => aliases.includes(alias)), + result.join(', '), + ); + }), + ); + }); }); + +// Helpers + +/** Any string, weighted toward names on Object.prototype, but not a term. */ +function notTermArbitrary() { + return fc + .oneof( + fc.string(), + fc.constantFrom(...Object.getOwnPropertyNames(Object.prototype)), + ) + .filter((value) => !terms.includes(value)); +}