diff --git a/tests/node-engine.test.mjs b/tests/node-engine.test.mjs new file mode 100644 index 0000000..211b53d --- /dev/null +++ b/tests/node-engine.test.mjs @@ -0,0 +1,133 @@ +/** + * Node engine test — locks in the [E00-S01-T08] Node engine restriction to + * 24.x for the workspace. + * + * Acceptance criteria covered (each test fails without the committed config): + * - "Node engine is restricted to 24.x in the package manifest" → root + * `package.json` declares `engines.node` exactly `>=24.0.0 <25.0.0`, and a + * minimal semver range check proves the range admits 24.x versions + * (24.0.0 … 24.99.99) while excluding every other major line. + * - "an unsupported Node version is rejected" → the same range check proves + * 18.x/22.x/23.x/25.x/26.x are outside the range, the committed + * `pnpm-workspace.yaml` sets `engineStrict: true` so pnpm hard-fails the + * install on those versions (ERR_PNPM_UNSUPPORTED_ENGINE) instead of + * warning, and the current runtime is asserted to satisfy the range (so + * `pnpm test` on an unsupported Node fails loudly). + * + * Run: `node --test tests/node-engine.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The exact engines.node value committed in the root package.json (24.x). */ +const NODE_ENGINE_RANGE = '>=24.0.0 <25.0.0'; + +/** Versions inside the 24.x line that must satisfy the range. */ +const SUPPORTED_EXAMPLES = ['24.0.0', '24.1.0', '24.20.0', '24.99.99']; + +/** Versions outside the 24.x line that must be rejected by the range. */ +const UNSUPPORTED_EXAMPLES = ['18.0.0', '20.0.0', '22.0.0', '22.23.2', '23.0.0', '25.0.0', '26.0.0']; + +// --------------------------------------------------------------------------- +// Minimal semver range checker (no dependencies, lockfile untouched) +// --------------------------------------------------------------------------- + +/** Parses "v?MAJOR.MINOR.PATCH" (pre-release/build suffixes ignored). */ +function parseVersion(version) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version).trim()); + assert.ok(match, `"${version}" is not a MAJOR.MINOR.PATCH semver version`); + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +/** Compares two [major, minor, patch] tuples: -1, 0 or 1. */ +function compareVersions(a, b) { + for (let i = 0; i < 3; i += 1) { + if (a[i] < b[i]) return -1; + if (a[i] > b[i]) return 1; + } + return 0; +} + +/** + * Checks a version against a whitespace-separated comparator range such as + * ">=24.0.0 <25.0.0" (AND semantics). Throws on any comparator form this + * workspace does not use, so an intentional range change must update this + * checker in the same change. + */ +function satisfies(version, range) { + const comparators = range.trim().split(/\s+/); + assert.ok(comparators.length > 0, `engines range must not be empty: "${range}"`); + const v = parseVersion(version); + return comparators.every((token) => { + const match = /^(>=|<=|>|<|=)?v?(\d+)\.(\d+)\.(\d+)$/.exec(token); + assert.ok(match, `unsupported comparator in engines range "${range}": "${token}"`); + const [, operator = '=', major, minor, patch] = match; + const c = compareVersions(v, [Number(major), Number(minor), Number(patch)]); + switch (operator) { + case '>=': return c >= 0; + case '<=': return c <= 0; + case '>': return c > 0; + case '<': return c < 0; + default: return c === 0; // '=' (exact) + } + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('root package.json declares engines.node restricted to 24.x', () => { + const manifest = JSON.parse(read('package.json')); + assert.equal( + manifest.engines?.node, + NODE_ENGINE_RANGE, + 'root package.json must declare engines.node exactly ">=24.0.0 <25.0.0" (24.x)', + ); + for (const version of SUPPORTED_EXAMPLES) { + assert.ok(satisfies(version, NODE_ENGINE_RANGE), `${version} must satisfy ${NODE_ENGINE_RANGE}`); + } +}); + +test('the declared engines range rejects unsupported Node versions', () => { + for (const version of UNSUPPORTED_EXAMPLES) { + assert.equal( + satisfies(version, NODE_ENGINE_RANGE), + false, + `${version} must be outside the 24.x range (${NODE_ENGINE_RANGE})`, + ); + } +}); + +test('the current Node runtime satisfies the declared engines range', () => { + assert.ok( + satisfies(process.versions.node, NODE_ENGINE_RANGE), + `runtime Node ${process.versions.node} is not within the supported 24.x line — ` + + 'install a Node 24.x release (install is rejected via engine-strict otherwise)', + ); +}); + +test('the committed pnpm-workspace.yaml enforces engines so an unsupported Node is rejected', () => { + const workspace = read('pnpm-workspace.yaml'); + assert.ok( + workspace.includes('engineStrict: true'), + 'pnpm-workspace.yaml must set engineStrict: true so pnpm rejects an unsupported Node version at install', + ); +}); + +test('committed CI workflow installs on Node 24', () => { + const workflow = read('.gitea/workflows/ci.yml'); + assert.ok( + workflow.includes("node-version: '24'"), + 'CI must run on Node 24 (the supported engines line)', + ); +});