Merge pull request #766 from BreezeDelegate/fix/node26-allocation-gates

fix(ci): run allocation gates on Node 26
This commit is contained in:
German Khokhlov authored and GitHub committed 2026-11-09 20:35:28 +04:00
commit 581ef7eef4
8 files changed
+46 -49

No files matched your search

@@ -0,0 +1 @@
- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (BreezeDelegate, #766; fixes #39).
+8 -10
View File
@@ -4141,10 +4141,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> style-switch persistence state. Browser/GPU performance comparison remains > style-switch persistence state. Browser/GPU performance comparison remains
> operator-side; the final accounting records only comparable population > operator-side; the final accounting records only comparable population
> reductions and deterministic Node allocation/solve measurements. Those > reductions and deterministic Node allocation/solve measurements. Those
> allocation gates were calibrated on Node.js 24.14.x and run in CI on the > allocation gates run on both supported Node majors (24 and 26) with
> pinned Node.js 24.22.2 with the same ceilings; `package.json` permits > unchanged byte ceilings; the existing CI matrix executes both probes
> supported product runtimes on Node 24 or 26, while the allocation runner > on its Node 24.22.2 and Node 26 entries. Each probe
> separately enforces Node 24 for these two calibrated probes. Each probe
> compiles synchronously and discards explicit-GC > compiles synchronously and discards explicit-GC
> transition chunks before applying the unchanged byte ceilings. The unit > transition chunks before applying the unchanged byte ceilings. The unit
> runner executes ordinary test files with Node's default parallelism, then > runner executes ordinary test files with Node's default parallelism, then
@@ -6076,12 +6075,11 @@ and unreachable upstream (502/504) separately from road geometry.
> **Reading `npm test` totals:** the count depends on the Node major. The two > **Reading `npm test` totals:** the count depends on the Node major. The two
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs` > GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the > = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) RUN on both
> calibrated Node 24 runtime; on any other major the runner skips both files and > supported Node 24 and Node 26. Unsupported majors may still skip those files
> their 14 tests are absent from the total. A branch total quoted without its > unless `GEV_REQUIRE_ALLOCATION_GATE=1` is set; every current supported CI
> Node version is therefore not reproducible. As of the fly_route cinematic > matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > runs can therefore remain lower because their 14 allocation tests were skipped.
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated - **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
+12 -12
View File
@@ -239,11 +239,11 @@ function satisfiesEngines(version, range) {
* re-resolve the interpreter from PATH and hand the gate a different Node than * re-resolve the interpreter from PATH and hand the gate a different Node than
* the one the check believes it selected. * the one the check believes it selected.
* @param {string} version e.g. "v24.19.0" * @param {string} version e.g. "v24.19.0"
* @returns {boolean} true only for a parseable Node 24. * @returns {boolean} true for a parseable supported Node 24 or 26.
*/ */
function isCalibratedAllocationRuntime(version) { function isCalibratedAllocationRuntime(version) {
const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10)); const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10));
return maj === 24; return maj === 24 || maj === 26;
} }
/** /**
@@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); };
// ─── A · REPO GATES ─────────────────────────────────────────────────────── // ─── A · REPO GATES ───────────────────────────────────────────────────────
check({ check({
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)', id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)',
run: async () => { run: async () => {
const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8')); const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8'));
const range = pkg.engines?.node || '(unset)'; const range = pkg.engines?.node || '(unset)';
@@ -557,7 +557,7 @@ check({
if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`); if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`);
// An off-range runtime is an environment problem, not a product defect — // An off-range runtime is an environment problem, not a product defect —
// but it silently disables the allocation gate, so say so loudly. A3 then // but it silently disables the allocation gate, so say so loudly. A3 then
// runs that gate under a discovered Node 24. // runs that gate under a discovered calibrated Node 24 fallback.
return env.node24 return env.node24
? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV') ? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV')
: fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`); : fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`);
@@ -579,13 +579,13 @@ check({
}); });
check({ check({
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)', id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors',
run: async () => { run: async () => {
// npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still // npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still
// exits 0 when the runtime is not Node 24. A green suite is therefore NOT // exits 0 when the runtime is outside the calibrated supported majors. A green
// proof the gate ran. Force it, under Node 24 when one is discoverable. // suite is therefore NOT proof the gate ran. Force it under a calibrated runtime.
// //
// ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm // ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm
// re-resolves the interpreter from PATH and can land back on the system // re-resolves the interpreter from PATH and can land back on the system
// Node even when THIS process is already 24: running the whole matrix under // Node even when THIS process is already 24: running the whole matrix under
// `mise exec [email protected] --`, the npm shell-out still re-execed system // `mise exec [email protected] --`, the npm shell-out still re-execed system
@@ -597,10 +597,10 @@ check({
const v = await sh(bin, ['--version'], { timeoutMs: 60000 }); const v = await sh(bin, ['--version'], { timeoutMs: 60000 });
const version = (v.out || '').trim(); const version = (v.out || '').trim();
if (!isCalibratedAllocationRuntime(version)) { if (!isCalibratedAllocationRuntime(version)) {
// run-unit-tests.mjs refuses to measure calibrated budgets off Node 24, // run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors,
// so a wrong binary means the gate never ran. That is this check failing // so a wrong binary means the gate never ran. That is this check failing
// to establish its own claim — not evidence about the product. // to establish its own claim — not evidence about the product.
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`); return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`);
} }
const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], { const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], {
timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' }, timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' },
@@ -611,9 +611,9 @@ check({
}; };
const [maj] = process.versions.node.split('.').map(Number); const [maj] = process.versions.node.split('.').map(Number);
if (maj === 24) return runGate(process.execPath, 'this runtime'); if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime');
if (env.node24) return runGate(env.node24.bin, env.node24.label); if (env.node24) return runGate(env.node24.bin, env.node24.label);
return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN'); return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN');
}, },
}); });
+11 -11
View File
@@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([
'src/overlays/worldOverlayAllocation.test.mjs', 'src/overlays/worldOverlayAllocation.test.mjs',
]); ]);
/** Whether this runtime matches the one the allocation budgets were calibrated on. */ /** Whether this runtime matches a supported major the allocation budgets are calibrated on. */
export function isCalibratedAllocationRuntime(version = process.versions.node) { export function isCalibratedAllocationRuntime(version = process.versions.node) {
return Number.parseInt(String(version).split('.')[0], 10) === 24; const major = Number.parseInt(String(version).split('.')[0], 10);
return major === 24 || major === 26;
} }
/** Require the runtime on which allocation budgets were calibrated. */ /** Require a runtime on which allocation budgets are calibrated. */
export function assertNode24AllocationRuntime(version = process.versions.node) { export function assertCalibratedAllocationRuntime(version = process.versions.node) {
if (!isCalibratedAllocationRuntime(version)) { if (!isCalibratedAllocationRuntime(version)) {
throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`); throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`);
} }
return version; return version;
} }
@@ -92,19 +93,18 @@ export function runUnitTests() {
const parallelStatus = runTests(['--test', ...plan.parallel]); const parallelStatus = runTests(['--test', ...plan.parallel]);
if (parallelStatus !== 0) return parallelStatus; if (parallelStatus !== 0) return parallelStatus;
// The GC-bracketed budgets are calibrated on Node 24 and are meaningless on // The GC-bracketed budgets are calibrated on every supported Node major.
// other allocators. A contributor's suite must stay green on any supported // Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the
// engine (package.json permits >=24), so uncalibrated runtimes skip the
// probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI / // probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI /
// release batteries) to make an uncalibrated runtime a hard failure. // release batteries) to make an uncalibrated runtime a hard failure.
if (!isCalibratedAllocationRuntime()) { if (!isCalibratedAllocationRuntime()) {
if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') { if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') {
assertNode24AllocationRuntime(); assertCalibratedAllocationRuntime();
} }
console.warn( console.warn(
`[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: ` `[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: `
+ `budgets are calibrated for Node 24, running ${process.versions.node}. ` + `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. `
+ 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).', + 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
); );
return 0; return 0;
} }
+1 -1
View File
@@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'
*/ */
test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => { test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => {
if (!isCalibratedAllocationRuntime()) { if (!isCalibratedAllocationRuntime()) {
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`); return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
} }
const result = spawnSync( const result = spawnSync(
process.execPath, process.execPath,
+1 -1
View File
@@ -333,7 +333,7 @@ function runAllocationProbe(entryCount, profile = 'generic') {
for (const workload of WORKLOADS) { for (const workload of WORKLOADS) {
test(`steady moving-source frames stay in budget ${workload.name}`, (t) => { test(`steady moving-source frames stay in budget ${workload.name}`, (t) => {
if (!CALIBRATED_ALLOCATION_RUNTIME) { if (!CALIBRATED_ALLOCATION_RUNTIME) {
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`); return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
} }
const payload = runAllocationProbe(workload.entries, workload.profile); const payload = runAllocationProbe(workload.entries, workload.profile);
+2 -1
View File
@@ -127,9 +127,10 @@ test('an unevaluable engines range reports unknown rather than guessing', () =>
// A full pass run under `mise exec node@24 --` still re-execed system Node 25 // A full pass run under `mise exec node@24 --` still re-execed system Node 25
// through npm; the forced gate refused ("received 25.6.1") and A3 reported it // through npm; the forced gate refused ("received 25.6.1") and A3 reported it
// as a product failure. A3 now invokes the binary directly and checks it. // as a product failure. A3 now invokes the binary directly and checks it.
test('only a parseable Node 24 counts as the calibrated allocation runtime', () => { test('every supported Node major counts as a calibrated allocation runtime', () => {
assert.equal(isCalibratedAllocationRuntime('v24.19.0'), true); assert.equal(isCalibratedAllocationRuntime('v24.19.0'), true);
assert.equal(isCalibratedAllocationRuntime('24.19.0'), true, 'the v prefix is optional'); assert.equal(isCalibratedAllocationRuntime('24.19.0'), true, 'the v prefix is optional');
assert.equal(isCalibratedAllocationRuntime('v26.3.0'), true);
assert.equal(isCalibratedAllocationRuntime('v25.6.1'), false, 'the npm re-resolution case'); assert.equal(isCalibratedAllocationRuntime('v25.6.1'), false, 'the npm re-resolution case');
assert.equal(isCalibratedAllocationRuntime('v22.21.1'), false); assert.equal(isCalibratedAllocationRuntime('v22.21.1'), false);
for (const junk of ['', ' ', 'node', undefined, null]) { for (const junk of ['', ' ', 'node', undefined, null]) {
+10 -13
View File
@@ -7,7 +7,7 @@ import {
ALLOCATION_TEST_FILES, ALLOCATION_TEST_FILES,
UNIT_TEST_ROOTS, UNIT_TEST_ROOTS,
allocationTestArgs, allocationTestArgs,
assertNode24AllocationRuntime, assertCalibratedAllocationRuntime,
buildUnitTestPlan, buildUnitTestPlan,
discoverUnitTestFiles, discoverUnitTestFiles,
isCalibratedAllocationRuntime, isCalibratedAllocationRuntime,
@@ -87,30 +87,27 @@ test('the runner discovers every test file in the repository', () => {
} }
}); });
test('allocation runtime calibration is explicit and pinned to Node 24', () => { test('allocation runtime calibration covers both supported Node majors', () => {
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); assert.equal(assertCalibratedAllocationRuntime('24.19.0'), '24.19.0');
assert.throws( assert.throws(
() => assertNode24AllocationRuntime('22.23.1'), () => assertCalibratedAllocationRuntime('22.23.1'),
/calibrated Node 24 runtime/, /calibrated Node 24 or 26/,
);
assert.throws(
() => assertNode24AllocationRuntime('26.0.0'),
/calibrated Node 24 runtime/,
); );
assert.equal(assertCalibratedAllocationRuntime('26.0.0'), '26.0.0');
assert.equal(isCalibratedAllocationRuntime('24.19.0'), true); assert.equal(isCalibratedAllocationRuntime('24.19.0'), true);
assert.equal(isCalibratedAllocationRuntime('22.23.1'), false); assert.equal(isCalibratedAllocationRuntime('22.23.1'), false);
assert.equal(isCalibratedAllocationRuntime('26.3.0'), false); assert.equal(isCalibratedAllocationRuntime('26.3.0'), true);
}); });
test('npm test stays green on every supported engine, not only the calibrated one', () => { test('npm test calibrates both supported engines while retaining off-range warnings', () => {
// package.json wiring: `npm test` must invoke this runner, and the engines // package.json wiring: `npm test` must invoke this runner, and the engines
// range it advertises must not be narrower than what the runner tolerates. // range it advertises must not be narrower than what the runner tolerates.
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
assert.equal(pkg.scripts.test, 'node scripts/run-unit-tests.mjs'); assert.equal(pkg.scripts.test, 'node scripts/run-unit-tests.mjs');
const enginesNode = String(pkg.engines?.node || ''); const enginesNode = String(pkg.engines?.node || '');
assert.ok(enginesNode, 'engines.node must be declared'); assert.ok(enginesNode, 'engines.node must be declared');
// The runner throws for uncalibrated runtimes ONLY behind the explicit // Unsupported runtimes only skip probes without the explicit opt-in hard gate.
// opt-in env; by default it skips, so a supported non-24 engine cannot fail. // Node 26 is calibrated and runs those probes with the same budgets.
const runner = readFileSync(new URL('../scripts/run-unit-tests.mjs', import.meta.url), 'utf8'); const runner = readFileSync(new URL('../scripts/run-unit-tests.mjs', import.meta.url), 'utf8');
assert.match(runner, /GEV_REQUIRE_ALLOCATION_GATE/); assert.match(runner, /GEV_REQUIRE_ALLOCATION_GATE/);
assert.match(runner, /SKIPPED .*allocation microbenchmarks/); assert.match(runner, /SKIPPED .*allocation microbenchmarks/);