Files
gstack/test/runtime-pins.test.ts
T
Garry Tanandcapy-ai[bot] 225a210378 v1.91.189.0 fix(ci): bounded Playwright install with one infra retry, PR runs get main's local branch, Bun install default 1.4.4 (GSTA-68) (#3368)
* fix(ci): bounded Playwright install with one infra retry, PR runs get main's local branch, Bun install default 1.4.4 (GSTA-68)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(runtime-pins): read the install-default job's pin from GSTACK_BUN_DEFAULT (GSTA-68)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.189.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-25 01:00:05 +00:00

222 lines
12 KiB
TypeScript

/**
* lib/runtime-pins.ts: the doctor's `project pins` row. Pins come from
* package.json, .tool-versions, .nvmrc, .bun-version and CI workflows with
* their file:line; a range and an exact pin are told apart; a CI pin keeps
* its platform lane and never fails another platform; an unevaluable value is
* reported, not dropped. The bin prints PINS_VERDICT last and exits 1 on fail.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { classify, collectPins, evaluatePins, renderPinLines, satisfies } from '../lib/runtime-pins';
const ROOT = path.resolve(import.meta.dir, '..');
const BUN_DEFAULT = fs.readFileSync(path.join(ROOT, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1];
const dirs: string[] = [];
afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); });
function project(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-pins-'));
dirs.push(dir);
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), text);
}
return dir;
}
const WORKFLOW = [
'name: ci', 'on: push', 'jobs:',
' linux:', ' runs-on: ubuntu-latest', ' steps:',
' - uses: oven-sh/setup-bun@v2', ' with:', ' bun-version: 1.4.2',
' windows:', ' runs-on: windows-latest', ' steps:',
' - uses: oven-sh/setup-bun@v2', ' with:', " bun-version: '1.4.2'",
' - uses: actions/setup-node@v7', ' with:', ' node-version: 24.18.0',
' matrix:', ' runs-on: ${{ matrix.os }}', ' steps:',
' - uses: oven-sh/setup-bun@v2', ' with:', ' bun-version: ${{ matrix.bun }}',
'',
].join('\n');
describe('satisfies and classify', () => {
test('ranges, exact pins, partial versions, wildcards and alternatives', () => {
expect(satisfies('1.3.14', '>=1.4.2')).toBe(false);
expect(satisfies('1.4.2', '>=1.4.2')).toBe(true);
expect(satisfies('1.4.5', '1.4.2')).toBe(false);
expect(satisfies('1.4.2', '1.4.2')).toBe(true);
expect(satisfies('24.18.0', '24')).toBe(true);
expect(satisfies('25.0.0', '24')).toBe(false);
expect(satisfies('24.18.0', '24.x')).toBe(true);
expect(satisfies('24.18.0', '^24.1.0')).toBe(true);
expect(satisfies('24.18.0', '~24.17.0')).toBe(false);
expect(satisfies('20.0.0', '>=18 <21')).toBe(true);
expect(satisfies('22.0.0', '>=18 <21 || >=22')).toBe(true);
expect(satisfies('1.0.0', '*')).toBe(true);
expect(satisfies('24.18.0', 'lts/*')).toBeNull();
expect(satisfies('24.18.0', '${{ matrix.node }}')).toBeNull();
expect(satisfies('garbage', '>=1')).toBeNull();
});
test('classify tells a range from an exact pin and names the unevaluable', () => {
expect(classify('>=1.4.2')).toBe('range');
expect(classify('1.4.2')).toBe('exact');
expect(classify('v1.4.2')).toBe('exact');
expect(classify('24')).toBe('range');
expect(classify('24.x')).toBe('range');
expect(classify('lts/*')).toBe('unevaluable');
});
});
describe('collectPins and evaluatePins', () => {
test('every source is read with file:line, CI pins carry their lane', () => {
const dir = project({
'package.json': '{\n "name": "x",\n "packageManager": "bun@1.4.2",\n "engines": {\n "bun": ">=1.4.2",\n "node": ">=22"\n }\n}\n',
'.tool-versions': 'nodejs 24.18.0\nbun 1.4.2\n',
'.nvmrc': 'lts/*\n',
'.bun-version': '1.4.2\n',
'.github/workflows/ci.yml': WORKFLOW,
});
const pins = collectPins(dir);
expect(pins.map(p => [p.tool, p.constraint, p.kind, p.source, p.field, p.lane])).toEqual([
['bun', '1.4.2', 'exact', 'package.json:3', 'packageManager', 'any'],
['bun', '>=1.4.2', 'range', 'package.json:5', 'engines.bun', 'any'],
['node', '>=22', 'range', 'package.json:6', 'engines.node', 'any'],
['node', '24.18.0', 'exact', '.tool-versions:1', '.tool-versions', 'any'],
['bun', '1.4.2', 'exact', '.tool-versions:2', '.tool-versions', 'any'],
['node', 'lts/*', 'unevaluable', '.nvmrc:1', '.nvmrc', 'any'],
['bun', '1.4.2', 'exact', '.bun-version:1', '.bun-version', 'any'],
['bun', '1.4.2', 'exact', '.github/workflows/ci.yml:9', 'CI', 'linux'],
['bun', '1.4.2', 'exact', '.github/workflows/ci.yml:15', 'CI', 'windows'],
['node', '24.18.0', 'exact', '.github/workflows/ci.yml:18', 'CI', 'windows'],
['bun', '${{ matrix.bun }}', 'unevaluable', '.github/workflows/ci.yml:24', 'CI', 'any'],
]);
});
test('a mismatch fails with the first source and the CI context; a Windows pin never blocks Linux', () => {
const dir = project({
'package.json': '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n',
'.github/workflows/ci.yml': WORKFLOW,
});
const linux = evaluatePins(collectPins(dir), { bun: '1.3.14', node: '22.0.0' }, 'linux');
expect(linux.verdict).toBe('fail');
expect(linux.failures).toEqual(['bun 1.3.14 outside engines.bun >=1.4.2 (package.json:3; CI linux lane, ci.yml:9 pins 1.4.2)']);
expect(linux.skipped).toEqual(['bun ${{ matrix.bun }} (.github/workflows/ci.yml:24) not evaluated']);
// node 22 differs from the Windows job's 24.18.0 pin, which does not apply on Linux.
expect(linux.failures.some(f => f.startsWith('node'))).toBe(false);
const windows = evaluatePins(collectPins(dir), { bun: '1.4.2', node: '22.0.0' }, 'win32');
expect(windows.verdict).toBe('fail');
expect(windows.failures).toEqual(['node 22.0.0 outside CI 24.18.0 (.github/workflows/ci.yml:18)']);
const ok = evaluatePins(collectPins(dir), { bun: '1.4.2', node: '24.18.0' }, 'linux');
expect(ok.verdict).toBe('pass');
expect(ok.failures).toEqual([]);
expect(ok.summary).toEqual([
'bun >=1.4.2 (engines.bun, package.json:3)',
'bun 1.4.2 (CI linux lane, ci.yml:9)',
'bun 1.4.2 (CI windows lane, ci.yml:15)',
'node 24.18.0 (CI windows lane, ci.yml:18)',
'bun ${{ matrix.bun }} (CI, ci.yml:24)',
]);
});
test('no pins is none, a missing runtime is skipped with a reason, and the wire lines end with the verdict', () => {
expect(evaluatePins(collectPins(project({ 'README.md': 'x' })), { bun: '1.4.2' }).verdict).toBe('none');
const dir = project({ '.nvmrc': '24\n' });
const report = evaluatePins(collectPins(dir), { bun: '1.4.2' }, 'linux');
expect(report.verdict).toBe('none');
expect(report.skipped).toEqual(['node 24 (.nvmrc:1): node not found on PATH']);
const lines = renderPinLines(report, 'linux');
expect(lines[0]).toBe('PIN: node 24 range .nvmrc:1 lane=any');
expect(lines.at(-1)).toBe('PINS_VERDICT: none');
});
test('a malformed package.json contributes no pins and does not throw', () => {
expect(collectPins(project({ 'package.json': '{ not json' }))).toEqual([]);
});
});
// Integration item 1: CI exact pins for this platform are alternatives (any
// one satisfies CI); non-CI pins stay required.
describe('CI exact pins are alternatives', () => {
const lane = (name: string, version: string, os = 'ubuntu-latest') => [` ${name}:`, ` runs-on: ${os}`, ' steps:', ' - uses: oven-sh/setup-bun@v2', ' with:', ` bun-version: ${version}`];
const workflow = (...jobs: string[][]) => ['jobs:', ...jobs.flat(), ''].join('\n');
test('the real gstack checkout (CI 1.4.2 lanes plus the install-default job) passes on 1.4.2 and on the install default, and names the alternatives on 1.4.1', () => {
const pins = collectPins(ROOT);
const ci = [...new Set(pins.filter(p => p.tool === 'bun' && p.field === 'CI' && p.kind === 'exact' && p.lane !== 'windows' && p.lane !== 'macos').map(p => p.constraint))].sort();
expect(ci).toEqual(['1.4.2', BUN_DEFAULT]);
for (const bun of ['1.4.2', BUN_DEFAULT]) expect(evaluatePins(pins, { bun }, 'linux').failures.filter(f => f.startsWith('bun'))).toEqual([]);
const low = evaluatePins(pins, { bun: '1.4.1' }, 'linux').failures.find(f => f.startsWith('bun '))!;
expect(low).toStartWith('bun 1.4.1 outside engines.bun >=1.4.2 (package.json:');
expect(low).toContain(`CI exact pins are alternatives, any one of 1.4.2, ${BUN_DEFAULT} satisfies CI`);
expect(renderPinLines(evaluatePins(pins, { bun: '1.4.2' }, 'linux'), 'linux')).toContain(`PIN_CI_ALTERNATIVES: bun 1.4.2 ${BUN_DEFAULT}`);
});
test('gbrain-like (every CI lane 1.4.3) still fails a local 1.4.2 exactly as before', () => {
const dir = project({ '.github/workflows/ci.yml': workflow(lane('a', '1.4.3'), lane('b', '1.4.3')) });
const fail = evaluatePins(collectPins(dir), { bun: '1.4.2' }, 'linux');
expect(fail.verdict).toBe('fail');
expect(fail.failures).toEqual(['bun 1.4.2 outside CI 1.4.3 (.github/workflows/ci.yml:7)']);
expect(evaluatePins(collectPins(dir), { bun: '1.4.3' }, 'linux').verdict).toBe('pass');
});
test('GBRA-39, gbrain-evals-like: one workflow pins 1.4.2, another 1.4.3; a local 1.4.3 used to fail "outside CI 1.4.2" and now passes', () => {
const dir = project({
'.github/workflows/test.yml': workflow(lane('unit', '1.4.3')),
'.github/workflows/evals.yml': workflow(lane('evals', '1.4.2')),
});
for (const bun of ['1.4.2', '1.4.3']) expect(evaluatePins(collectPins(dir), { bun }, 'linux').verdict).toBe('pass');
expect(evaluatePins(collectPins(dir), { bun: '1.4.4' }, 'linux').failures).toEqual([expect.stringContaining('any one of ')]);
});
test('non-CI exact pins stay required beside CI alternatives; another platform\'s CI pin is not an alternative here', () => {
const dir = project({
'.bun-version': '1.4.2\n',
'.github/workflows/ci.yml': workflow(lane('linux', '1.4.3'), lane('mac', '1.4.4', 'macos-latest')),
});
const r = evaluatePins(collectPins(dir), { bun: '1.4.3' }, 'linux');
expect(r.failures).toEqual(['bun 1.4.3 outside .bun-version 1.4.2 (.bun-version:1)']);
const lines = renderPinLines(r, 'linux');
expect(lines).toContain('PIN_EXACT_REQUIRED: bun 1.4.2 .bun-version:1');
expect(lines).toContain('PIN_CI_ALTERNATIVES: bun 1.4.3');
expect(evaluatePins(collectPins(dir), { bun: '1.4.4' }, 'linux').verdict).toBe('fail');
});
test('Node pins render the same plan lines as Bun: .nvmrc required, CI node-version pins in different jobs alternatives', () => {
const dir = project({
'.nvmrc': '24.18.0\n',
'.github/workflows/ci.yml': 'jobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - with:\n node-version: 24.18.0\n b:\n runs-on: ubuntu-latest\n steps:\n - with:\n node-version: 22.20.0\n',
});
const lines = renderPinLines(evaluatePins(collectPins(dir), { node: '24.18.0' }, 'linux'), 'linux');
expect(lines).toContain('PIN_EXACT_REQUIRED: node 24.18.0 .nvmrc:1');
expect(lines).toContain('PIN_CI_ALTERNATIVES: node 24.18.0 22.20.0');
expect(lines.at(-1)).toBe('PINS_VERDICT: pass');
expect(evaluatePins(collectPins(dir), { node: '24.21.0' }, 'linux').failures).toEqual([expect.stringMatching(/^node 24\.21\.0 outside \.nvmrc 24\.18\.0/)]);
});
});
describe('bin/gstack-runtime-pins.ts', () => {
const run = (args: string[]) => spawnSync(process.execPath, [path.join(ROOT, 'bin/gstack-runtime-pins.ts'), ...args], { encoding: 'utf8', timeout: 30_000 });
test('prints PINS_VERDICT last, exits 1 on fail and 0 on pass, --json carries the same report', () => {
const dir = project({ 'package.json': '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n' });
const fail = run(['--project', dir, '--bun', '1.3.14', '--platform', 'linux']);
expect(fail.status).toBe(1);
expect(fail.stdout.trim().split('\n').at(-1)).toBe('PINS_VERDICT: fail');
expect(fail.stdout).toContain('PIN_FAIL: bun 1.3.14 outside engines.bun >=1.4.2 (package.json:3)');
const pass = run(['--project', dir, '--bun', '1.4.2', '--json']);
expect(pass.status).toBe(0);
const json = JSON.parse(pass.stdout);
expect(json.schema_version).toBe(1);
expect(json.verdict).toBe('pass');
expect(json.pins).toHaveLength(1);
});
test('usage errors exit 2', () => {
expect(run([]).status).toBe(2);
expect(run(['--project', path.join(os.tmpdir(), 'no-such-dir-for-pins')]).status).toBe(2);
});
});