mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
v1.91.71.0 feat(install): gstack-doctor --check one-line verdict, gstack-capy-install, lazy Chromium (multi-agent wave PR A) (#3116)
* v1.91.71.0 feat(install): gstack-doctor --check one-line verdict, gstack-capy-install, lazy Chromium Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(qa-callers): substitute <plan-path> in the plan-completion acceptance block so the ship caller fixture admits the documented command 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>
This commit is contained in:
1 parent
45a678323b
commit
6681adf7a8
49 files changed
+2700
-98
No files matched your search
@@ -142,6 +142,18 @@ describe('Bun requirement surfaces agree (E1)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the Capy installer\'s linux-x64 archive checksum is the one Dockerfile.ci verifies', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-bun-version.sh'), 'utf-8');
|
||||
const dockerfile = fs.readFileSync(path.join(ROOT, '.github', 'docker', 'Dockerfile.ci'), 'utf-8');
|
||||
const shSha = src.match(/^\s*linux-x64\) echo ([0-9a-f]{64}) ;;$/m)?.[1];
|
||||
const dockerSha = dockerfile.match(/bun_target=linux-x64; bun_sha256=([0-9a-f]{64})/)?.[1];
|
||||
expect(shSha, 'gstack_bun_archive_sha256 linux-x64').toBeDefined();
|
||||
expect(shSha).toBe(dockerSha);
|
||||
const r = spawnSync('bash', ['-c', `. "${path.join(ROOT, 'bin', 'gstack-bun-version.sh')}" && gstack_bun_archive_sha256 linux-x64 && gstack_bun_archive_sha256 linux-x64-baseline && gstack_bun_archive_sha256 linux-aarch64 && ! gstack_bun_archive_sha256 darwin-arm64`], { encoding: 'utf-8', timeout: 10_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout.trim().split('\n')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('BROWSE SETUP install hint pins the tested version', () => {
|
||||
const resolver = fs.readFileSync(path.join(ROOT, 'scripts', 'resolvers', 'browse.ts'), 'utf-8');
|
||||
expect([...resolver.matchAll(/BUN_VERSION="([0-9][^"]*)"/g)].map((m) => m[1])).toEqual([tested]);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The doctor's component table: one source (lib/doctor-components.ts), a
|
||||
* committed Bash render the doctor sources without Bun
|
||||
* (bin/gstack-doctor-components.sh) that must stay fresh, every skill
|
||||
* directory classified, and every result code anchored in
|
||||
* docs/troubleshooting.md through the same render.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
COMPONENT_IDS, CORE_IDS, DOCTOR_COMPONENTS, SKILL_REQUIREMENTS, renderDoctorComponentsSh,
|
||||
} from '../lib/doctor-components';
|
||||
import { RESULT_CODES } from '../lib/result-codes';
|
||||
import { DOCTOR_COMPONENTS_RELPATH } from '../scripts/gen-doctor-components';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const bash = (script: string) => spawnSync('bash', ['-c', `. "${path.join(ROOT, DOCTOR_COMPONENTS_RELPATH)}" && ${script}`], { encoding: 'utf8', timeout: 20_000 });
|
||||
|
||||
describe('doctor component table', () => {
|
||||
test('the committed Bash render is fresh (matches the generator)', () => {
|
||||
expect(fs.readFileSync(path.join(ROOT, DOCTOR_COMPONENTS_RELPATH), 'utf8')).toBe(renderDoctorComponentsSh());
|
||||
});
|
||||
|
||||
test('ids are unique kebab-case and every skill requirement names known ids', () => {
|
||||
expect(new Set(COMPONENT_IDS).size).toBe(COMPONENT_IDS.length);
|
||||
for (const id of COMPONENT_IDS) expect(id).toMatch(/^[a-z][a-z-]*$/);
|
||||
for (const [skill, req] of Object.entries(SKILL_REQUIREMENTS)) {
|
||||
for (const id of [...req.requires, ...req.optional]) expect(COMPONENT_IDS, `${skill} -> ${id}`).toContain(id);
|
||||
for (const id of req.requires) expect(CORE_IDS, `${skill} lists core row ${id}; core rows are implied`).not.toContain(id);
|
||||
expect(req.requires.filter(id => req.optional.includes(id))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('every skill directory in the repo is classified, and no classified skill is missing', () => {
|
||||
const skills = fs.readdirSync(ROOT).filter(d => fs.existsSync(path.join(ROOT, d, 'SKILL.md.tmpl'))).sort();
|
||||
expect(skills.length).toBeGreaterThan(40);
|
||||
expect(Object.keys(SKILL_REQUIREMENTS).sort()).toEqual(skills);
|
||||
});
|
||||
|
||||
test('the Bash render answers the same questions as the table, and refuses unknown names', () => {
|
||||
const r = bash([
|
||||
'gstack_doctor_skill_requires autoplan; echo',
|
||||
'gstack_doctor_skill_optional autoplan; echo',
|
||||
'gstack_doctor_skill_requires qa; echo',
|
||||
'gstack_doctor_component_kind codex-cli; echo',
|
||||
'gstack_doctor_component_title pins; echo',
|
||||
'gstack_result_anchor REVISION_UNMET; echo',
|
||||
'gstack_doctor_skill_requires no-such-skill; echo "rc=$?"',
|
||||
'gstack_doctor_component_kind nope; echo "rc=$?"',
|
||||
'echo "$GSTACK_DOCTOR_CORE"',
|
||||
].join('; '));
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout.split('\n')).toEqual([
|
||||
SKILL_REQUIREMENTS.autoplan.requires.join(' '),
|
||||
SKILL_REQUIREMENTS.autoplan.optional.join(' '),
|
||||
SKILL_REQUIREMENTS.qa.requires.join(' '),
|
||||
'optional',
|
||||
'project pins',
|
||||
RESULT_CODES.REVISION_UNMET.anchor,
|
||||
'rc=1',
|
||||
'rc=1',
|
||||
CORE_IDS.join(' '),
|
||||
'',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the render is Bash 3.2 syntax: no arrays, no [[ ]], no ${var//}', () => {
|
||||
const text = renderDoctorComponentsSh();
|
||||
expect(text).not.toMatch(/\[\[|\]\]|\$\{[^}]*\/\/|=\(/);
|
||||
for (const c of DOCTOR_COMPONENTS) expect(text).toContain(`${c.id}) printf '%s' '${c.kind}'`);
|
||||
});
|
||||
|
||||
test('the Astra-patch and browser rows are the installer contract', () => {
|
||||
expect(SKILL_REQUIREMENTS.autoplan.requires).toEqual(['claude', 'codex', 'patch']);
|
||||
expect(SKILL_REQUIREMENTS.autoplan.optional).toContain('codex-cli');
|
||||
expect(SKILL_REQUIREMENTS.browse.requires).toContain('browser');
|
||||
expect(SKILL_REQUIREMENTS.codex.requires).toContain('codex-cli');
|
||||
expect(SKILL_REQUIREMENTS.cso.requires).toContain('cso');
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,10 @@ const SCANNER_EXEMPT: Record<string, string> = {
|
||||
'every git clone is inside an echoed instruction string (install docs); the script executes no network ops',
|
||||
'bin/gstack-gbrain-install':
|
||||
'user-invoked installer: bodyless HEAD reachability probe to github.com + clone of the public gbrain repo (user-directed install; no gstack state leaves the machine)',
|
||||
'bin/gstack-capy-install':
|
||||
'user-invoked installer that runs before any gstack checkout exists (so the egress lib is not yet available): clone of the public gstack repo and a download of the pinned Bun release archive from GitHub, SHA-256 verified before anything is installed; no gstack state leaves the machine',
|
||||
'bin/gstack-doctor-check.sh':
|
||||
'git fetch appears only in the revision row\'s fix string (the command the user runs to move the checkout)',
|
||||
'bin/gstack-next-version':
|
||||
'fetches the user\'s own repo\'s base branch for version-claim freshness — a user-repo dev-workflow op, not gstack-state egress',
|
||||
'bin/gstack-version-bump':
|
||||
|
||||
+5
@@ -47,6 +47,11 @@
|
||||
{ "file": "test/helpers/pty/screen.ts", "movedFrom": "test/helpers/pty-screen.ts" },
|
||||
{ "file": "test/helpers/pty/session.ts", "movedFrom": "test/helpers/claude-pty-runner.ts" },
|
||||
{ "file": "lib/memory-ingest-landing.ts", "movedFrom": "(new: A1 memory ingest landing check, state transitions and reconcile)" },
|
||||
{ "file": "lib/doctor-components.ts", "movedFrom": "(new: the gstack-doctor --check component table, rendered into bin/gstack-doctor-components.sh)" },
|
||||
{ "file": "lib/result-codes.ts", "movedFrom": "(new: reason codes with troubleshooting anchors for workflow-command error lines, sibling of lib/gate-outcomes.ts)" },
|
||||
{ "file": "lib/runtime-pins.ts", "movedFrom": "(new: the doctor's project pins row; reads a target repository's Bun and Node pins)" },
|
||||
{ "file": "bin/gstack-runtime-pins.ts", "movedFrom": "(new: thin entrypoint over lib/runtime-pins.ts)" },
|
||||
{ "file": "scripts/gen-doctor-components.ts", "movedFrom": "(new: renders lib/doctor-components.ts into bin/gstack-doctor-components.sh)" },
|
||||
{ "file": "lib/gate-diff/classify.ts", "movedFrom": "(new: gate-edit detector path floor and CLAUDE.md Gate Integrity block, W1)" },
|
||||
{ "file": "lib/gate-diff/tags.ts", "movedFrom": "(new: gate-edit detector RH tags, W1)" },
|
||||
{ "file": "lib/gate-diff/git.ts", "movedFrom": "(new: gate-edit detector candidate model and hardened git calls, W1)" },
|
||||
|
||||
@@ -37,7 +37,7 @@ afterAll(() => fs.rmSync(base, { recursive: true, force: true }));
|
||||
describe('generator artifact and dry-run contract', () => {
|
||||
test('all host artifacts validate, with canonical links and host exclusions', () => {
|
||||
expect([...new Set(generated.artifacts.filter(a => a.host).map(a => a.host))].sort()).toEqual([...ALL_HOST_NAMES].sort());
|
||||
expect([...new Set(generated.artifacts.map(a => a.kind))].sort()).toEqual(['asset', 'digest', 'index', 'metadata', 'openclaw', 'section', 'skill']);
|
||||
expect([...new Set(generated.artifacts.map(a => a.kind))].sort()).toEqual(['asset', 'digest', 'doctor-components', 'index', 'metadata', 'openclaw', 'section', 'skill']);
|
||||
expect(generated.artifacts.some(a => a.relativePath === 'claude-code/SKILL.md')).toBe(false);
|
||||
expect(generated.artifacts.some(a => a.relativePath === '.agents/skills/gstack-claude-code/SKILL.md')).toBe(true);
|
||||
expect(generated.artifacts.some(a => a.relativePath === '.agents/skills/gstack-codex/SKILL.md')).toBe(false);
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('gen-skill-docs stale-render prune', () => {
|
||||
const source = path.join(out, 'source');
|
||||
fs.mkdirSync(path.join(source, 'scripts'), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'scripts', 'gen-skill-docs.ts'), path.join(source, 'scripts', 'gen-skill-docs.ts'));
|
||||
for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'models.ts']) {
|
||||
for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts']) {
|
||||
fs.symlinkSync(path.join(ROOT, 'scripts', file), path.join(source, 'scripts', file), 'file');
|
||||
}
|
||||
fs.symlinkSync(path.join(ROOT, 'scripts', 'resolvers'), path.join(source, 'scripts', 'resolvers'), 'dir');
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* bin/gstack-browser-ensure: the lazy Chromium install the browse preflight
|
||||
* runs on first use. Pinned on the wire lines (BROWSER_OK / BROWSER_UNAVAILABLE),
|
||||
* exit codes, the no-install `--check` mode, the install-then-reprobe path
|
||||
* under setup's lock, and the preflight wiring in scripts/resolvers/browse.ts.
|
||||
* Chromium itself is a stub `node_modules/playwright` whose launch succeeds
|
||||
* only once a marker the stubbed `bunx playwright install` writes exists.
|
||||
*/
|
||||
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 { generateBrowseFallback, generateBrowseSetup } from '../scripts/resolvers/browse';
|
||||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||||
|
||||
const ctxFor = (host: 'claude' | 'codex', skillName: string) => ({ skillName, tmplPath: '', host, paths: HOST_PATHS[host] });
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const bases: string[] = [];
|
||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
const write = (file: string, text: string, mode = 0o644) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, text, { mode });
|
||||
};
|
||||
|
||||
function fixture(opts: { playwright?: boolean } = {}) {
|
||||
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'browser-ensure-')));
|
||||
bases.push(base);
|
||||
const root = path.join(base, 'gstack');
|
||||
const stub = path.join(base, 'stub');
|
||||
const marker = path.join(base, 'chromium-installed');
|
||||
fs.mkdirSync(stub, { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
|
||||
fs.copyFileSync(path.join(REPO, 'bin/gstack-browser-ensure'), path.join(root, 'bin/gstack-browser-ensure'));
|
||||
fs.chmodSync(path.join(root, 'bin/gstack-browser-ensure'), 0o755);
|
||||
if (opts.playwright !== false) {
|
||||
write(path.join(root, 'node_modules/playwright/index.js'), [
|
||||
'const fs = require("fs");',
|
||||
`const marker = ${JSON.stringify(marker)};`,
|
||||
'module.exports.chromium = { launch: async () => {',
|
||||
' if (!fs.existsSync(marker)) throw new Error("browserType.launch: Executable doesn\'t exist at /nowhere/chrome");',
|
||||
' return { version: () => "141.0.7390.37", close: async () => {}, newPage: async () => ({ setContent: async () => {}, title: async () => "gstack smoke test" }) };',
|
||||
'} };',
|
||||
].join('\n'));
|
||||
}
|
||||
// `bunx playwright install chromium` "installs" by writing the marker and logging its argv.
|
||||
write(path.join(stub, 'bunx'), `#!/usr/bin/env bash\necho "$*" >> "${base}/bunx-calls"\ncase "$*" in *"playwright install"*) touch "${marker}" ;; *) exit 9 ;; esac\n`, 0o755);
|
||||
write(path.join(stub, 'bun'), '#!/usr/bin/env bash\n[ "$1" = --version ] && { echo 1.4.2; exit 0; }\nexit 0\n', 0o755);
|
||||
const env: Record<string, string> = { PATH: `${stub}${path.delimiter}${process.env.PATH ?? ''}`, HOME: base, TMPDIR: base, GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '20' };
|
||||
const run = (args: string[] = [], extra: Record<string, string> = {}) =>
|
||||
spawnSync('bash', [path.join(root, 'bin/gstack-browser-ensure'), ...args], { encoding: 'utf8', env: { ...env, ...extra }, timeout: 60_000 });
|
||||
return { base, root, marker, run, calls: () => fs.existsSync(path.join(base, 'bunx-calls')) ? fs.readFileSync(path.join(base, 'bunx-calls'), 'utf8') : '' };
|
||||
}
|
||||
|
||||
const lastLine = (s: string) => s.trim().split('\n').at(-1);
|
||||
|
||||
describe('gstack-browser-ensure', () => {
|
||||
test('--check never installs: unavailable Chromium is BROWSER_UNAVAILABLE, exit 1, with the fix line and code', () => {
|
||||
const f = fixture();
|
||||
const r = f.run(['--check']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(lastLine(r.stdout)).toMatch(/^BROWSER_UNAVAILABLE Chromium did not launch via node \(browserType\.launch: Executable doesn't exist/);
|
||||
expect(r.stderr).toContain('(BROWSER_UNAVAILABLE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#browser-ensure-failed)');
|
||||
expect(r.stderr).toContain(`fix: ${f.root}/bin/gstack-browser-ensure`);
|
||||
expect(f.calls()).toBe('');
|
||||
expect(fs.existsSync(f.marker)).toBe(false);
|
||||
});
|
||||
|
||||
test('a missing node_modules is reported, not installed, under --check', () => {
|
||||
const f = fixture({ playwright: false });
|
||||
const r = f.run(['--check']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(lastLine(r.stdout)).toBe(`BROWSER_UNAVAILABLE playwright is not installed under ${f.root}/node_modules`);
|
||||
expect(r.stderr).toContain(`fix: cd ${f.root} && ./setup`);
|
||||
});
|
||||
|
||||
test('first use installs Chromium under setup\'s lock, re-probes and prints BROWSER_OK; the second run is a probe only', () => {
|
||||
const f = fixture();
|
||||
const first = f.run();
|
||||
expect(first.status, first.stdout + first.stderr).toBe(0);
|
||||
expect(lastLine(first.stdout)).toBe('BROWSER_OK node 141.0.7390.37');
|
||||
expect(f.calls().trim()).toBe('playwright install chromium');
|
||||
expect(first.stderr).toContain('installing Playwright Chromium');
|
||||
expect(fs.existsSync(path.join(f.base, 'gstack-playwright-install.lock'))).toBe(false);
|
||||
const second = f.run(['--quiet']);
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stdout.trim()).toBe('BROWSER_OK node 141.0.7390.37');
|
||||
expect(second.stderr).toBe('');
|
||||
expect(f.calls().trim()).toBe('playwright install chromium');
|
||||
});
|
||||
|
||||
test('--with-deps reaches the installer; a held lock by a live process is waited on, a dead holder is reclaimed', () => {
|
||||
const f = fixture();
|
||||
const lock = path.join(f.base, 'gstack-playwright-install.lock');
|
||||
fs.mkdirSync(lock);
|
||||
fs.writeFileSync(path.join(lock, 'pid'), '999999999\n');
|
||||
const r = f.run(['--with-deps']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(f.calls().trim()).toBe('playwright install --with-deps chromium');
|
||||
expect(fs.existsSync(lock)).toBe(false);
|
||||
});
|
||||
|
||||
test('an installer failure is BROWSER_UNAVAILABLE with the exit code; usage errors exit 2; --help exits 0', () => {
|
||||
const f = fixture();
|
||||
fs.writeFileSync(path.join(f.base, 'stub/bunx'), '#!/usr/bin/env bash\nexit 7\n');
|
||||
const r = f.run();
|
||||
expect(r.status).toBe(1);
|
||||
expect(lastLine(r.stdout)).toBe('BROWSER_UNAVAILABLE bunx playwright install chromium exited 7 (offline, proxy, or blocked download?)');
|
||||
expect(f.run(['--bogus']).status).toBe(2);
|
||||
const help = f.run(['--help']);
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toContain('--check');
|
||||
});
|
||||
});
|
||||
|
||||
describe('browse preflight wiring (scripts/resolvers/browse.ts)', () => {
|
||||
test('every browser skill\'s $B detection runs gstack-browser-ensure from the install root before READY', () => {
|
||||
for (const host of ['claude', 'codex'] as const) {
|
||||
const fallback = generateBrowseFallback(ctxFor(host, 'browse'));
|
||||
expect(fallback).toContain('[ -x "$B" ] && "${B%/browse/*}/bin/gstack-browser-ensure" && echo "READY: $B" || echo "NEEDS_SETUP"');
|
||||
const setup = generateBrowseSetup(ctxFor(host, 'pair-agent'));
|
||||
expect(setup).toContain('if [ -x "$B" ] && "${B%/browse/*}/bin/gstack-browser-ensure"; then');
|
||||
expect(setup).toContain('BROWSER_UNAVAILABLE <reason>');
|
||||
}
|
||||
});
|
||||
|
||||
test('the suffix strip resolves the install root for every $B shape', () => {
|
||||
const r = spawnSync('bash', ['-c', 'for B in /home/u/.claude/skills/gstack/browse/dist/browse /r/.agents/skills/gstack/browse/dist/browse.exe /home/browse/.claude/skills/gstack/browse/dist/browse; do echo "${B%/browse/*}"; done'], { encoding: 'utf8', timeout: 10_000 });
|
||||
expect(r.stdout.trim().split('\n')).toEqual(['/home/u/.claude/skills/gstack', '/r/.agents/skills/gstack', '/home/browse/.claude/skills/gstack']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* bin/gstack-capy-install: the repo-owned Capy installer. Pinned on the order
|
||||
* of operations (preflight before ./setup), exit codes (3 before anything is
|
||||
* installed), the setup argv it passes (--no-browser unless --with-browser),
|
||||
* the lazy-browser SKIP line, the GSTACK_LAUNCH line, the trailer as the
|
||||
* last line with the `(requested <ref>; ...)` note, and the Bun recipe (exact
|
||||
* archive, SHA-256 verified, only when gstack and the project agree). ./setup,
|
||||
* gstack-doctor, gstack-browser-ensure, bun, curl and node are stubs that log
|
||||
* their argv; git, jq, unzip and sha256sum are real.
|
||||
*/
|
||||
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';
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const bases: string[] = [];
|
||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
const write = (file: string, text: string, mode = 0o644) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, text, { mode });
|
||||
};
|
||||
const script = (file: string, lines: string[]) => write(file, ['#!/usr/bin/env bash', ...lines].join('\n') + '\n', 0o755);
|
||||
const sh = (cmd: string, cwd?: string) => spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', timeout: 30_000 });
|
||||
|
||||
/** PATH without any directory that holds bun, node, curl or jq-less shims; real git/jq/unzip/sha256sum stay reachable. */
|
||||
function hostPath(base: string, hide: string[]): string {
|
||||
return (process.env.PATH ?? '').split(path.delimiter).filter(Boolean).map((dir, i) => {
|
||||
if (!hide.some(n => fs.existsSync(path.join(dir, n)))) return dir;
|
||||
const shim = path.join(base, `host-${i}`);
|
||||
fs.mkdirSync(shim, { recursive: true });
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (hide.includes(name)) continue;
|
||||
try { fs.symlinkSync(path.join(dir, name), path.join(shim, name)); } catch { /* unreadable */ }
|
||||
}
|
||||
return shim;
|
||||
}).join(path.delimiter);
|
||||
}
|
||||
|
||||
function fixture(opts: { bun?: string; jq?: boolean } = {}) {
|
||||
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'capy-install-')));
|
||||
bases.push(base);
|
||||
const root = path.join(base, 'gstack');
|
||||
const home = path.join(base, 'home');
|
||||
const stub = path.join(base, 'stub');
|
||||
const log = path.join(base, 'calls.log');
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(stub, { recursive: true });
|
||||
for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh']) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.copyFileSync(path.join(REPO, rel), path.join(root, rel));
|
||||
fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode);
|
||||
}
|
||||
write(path.join(root, 'VERSION'), '1.2.3\n');
|
||||
script(path.join(root, 'setup'), ['echo "setup $*" >> "$CAPY_LOG"', 'echo "env GSTACK_SKIP_PLAYWRIGHT=${GSTACK_SKIP_PLAYWRIGHT:-} GSTACK_SKIP_FONTS=${GSTACK_SKIP_FONTS:-} bun=$(bun --version)" >> "$CAPY_LOG"', 'exit "${STUB_SETUP_EXIT:-0}"']);
|
||||
script(path.join(root, 'bin/gstack-doctor'), [
|
||||
'echo "doctor $* GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-}" >> "$CAPY_LOG"', 'echo "doctor-cwd $(pwd -P)" >> "$CAPY_LOG"',
|
||||
'echo "required: claude codex patch"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"',
|
||||
'[ "${STUB_DOCTOR_FAIL:-}" = 1 ] && { echo "FAIL codex — missing; fix: x (COMPONENT_MISSING; d)"; echo "gstack: fail codex"; exit 1; }',
|
||||
'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"',
|
||||
]);
|
||||
script(path.join(root, 'bin/gstack-paths'), ['[ "$1" = --get ] && { echo "${GSTACK_HOME}"; exit 0; }', 'exit 1']);
|
||||
script(path.join(root, 'bin/gstack-browser-ensure'), ['echo "browser-ensure $*" >> "$CAPY_LOG"', 'echo "BROWSER_OK node 141.0.1"']);
|
||||
sh(`git init -q && git add -A && git -c user.email=t@t -c user.name=t commit -qm init`, root);
|
||||
script(path.join(stub, 'bun'), [`[ "$1" = --version ] && { echo "${opts.bun ?? '1.4.2'}"; exit 0; }`, 'exit 0']);
|
||||
script(path.join(stub, 'node'), ['[ "$1" = --version ] && { echo v24.18.0; exit 0; }', 'exit 0']);
|
||||
script(path.join(stub, 'curl'), ['echo "curl $*" >> "$CAPY_LOG"', 'out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done', '[ -n "${STUB_CURL_ZIP:-}" ] && cp "$STUB_CURL_ZIP" "$out" && exit 0', 'exit 22']);
|
||||
if (opts.jq !== false) {
|
||||
const real = sh('command -v jq').stdout.trim();
|
||||
if (real) fs.symlinkSync(real, path.join(stub, 'jq'));
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
PATH: `${stub}${path.delimiter}${hostPath(base, ['bun', 'node', 'curl', 'jq'])}`,
|
||||
HOME: home, GSTACK_HOME: path.join(home, '.gstack-state'), TMPDIR: base, CAPY_LOG: log, BUN_INSTALL: path.join(home, '.bun'),
|
||||
};
|
||||
const run = (args: string[] = [], extra: Record<string, string> = {}, cwd = home) =>
|
||||
spawnSync('bash', [path.join(root, 'bin/gstack-capy-install'), ...args], { cwd, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 });
|
||||
return { base, root, home, stub, run, calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n') : [] };
|
||||
}
|
||||
|
||||
const lastLine = (s: string) => s.trim().split('\n').at(-1);
|
||||
|
||||
describe('gstack-capy-install', () => {
|
||||
test('default install: preflight, both host setups with --no-browser, lazy browser SKIP, launch line, trailer last, exit 0', () => {
|
||||
const f = fixture();
|
||||
const r = run(f);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(f.calls().filter(l => l.startsWith('setup '))).toEqual([
|
||||
'setup --host claude --no-prefix --no-browser',
|
||||
'setup --host codex --model gpt-6-astra --no-browser',
|
||||
]);
|
||||
expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill('env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=1.4.2'));
|
||||
expect(f.calls().some(l => l.startsWith('browser-ensure'))).toBe(false);
|
||||
expect(f.calls().filter(l => l.startsWith('doctor '))).toEqual(['doctor --check --require claude,codex,patch GSTACK_EPHEMERAL=1']);
|
||||
expect(f.calls().filter(l => l.startsWith('doctor-cwd '))).toEqual([`doctor-cwd ${f.home}`]);
|
||||
expect(r.stdout).toContain('SKIP browser (lazy; gstack-browser-ensure installs on first use)');
|
||||
expect(r.stdout).toMatch(/planning-only install: \d+s/);
|
||||
expect(r.stdout).toContain(`GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=${path.join(f.home, '.gstack-state')} GSTACK_EPHEMERAL=1`);
|
||||
expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
|
||||
expect(r.stdout).toContain('reusing');
|
||||
expect(r.stdout).not.toContain('cloning');
|
||||
expect(f.calls().some(l => l.startsWith('curl '))).toBe(false);
|
||||
});
|
||||
|
||||
test('--for names the workflow; --with-browser drops --no-browser, runs gstack-browser-ensure and times it separately', () => {
|
||||
const f = fixture();
|
||||
const r = run(f, ['--for', 'qa', '--with-browser']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(f.calls().filter(l => l.startsWith('setup '))).toEqual(['setup --host claude --no-prefix', 'setup --host codex --model gpt-6-astra']);
|
||||
expect(f.calls().filter(l => l.startsWith('browser-ensure'))).toEqual(['browser-ensure ']);
|
||||
expect(f.calls().filter(l => l.startsWith('doctor '))).toEqual(['doctor --check --for qa GSTACK_EPHEMERAL=1']);
|
||||
expect(r.stdout).toMatch(/browser bootstrap: \d+s/);
|
||||
expect(r.stdout).not.toContain('SKIP browser (lazy; gstack-browser-ensure');
|
||||
const g = fixture();
|
||||
const req = run(g, ['--with-browser']);
|
||||
expect(req.status).toBe(0);
|
||||
expect(g.calls().filter(l => l.startsWith('doctor '))).toEqual(['doctor --check --require claude,codex,patch,browse-bundle,browser GSTACK_EPHEMERAL=1']);
|
||||
});
|
||||
|
||||
test('a FAIL from the doctor is exit 1 with its trailer last; usage errors exit 2', () => {
|
||||
const f = fixture();
|
||||
const r = run(f, [], { STUB_DOCTOR_FAIL: '1' });
|
||||
expect(r.status).toBe(1);
|
||||
expect(lastLine(r.stdout)).toBe('gstack: fail codex');
|
||||
expect(r.stdout).toContain('FAIL codex');
|
||||
expect(run(f, ['--bogus']).status).toBe(2);
|
||||
expect(run(f, ['--for', 'qa', '--require', 'claude']).status).toBe(2);
|
||||
expect(run(f, ['--revision']).status).toBe(2);
|
||||
});
|
||||
|
||||
test('preflight: a missing tool stops with exit 3 and INSTALL_PREFLIGHT_FAILED before ./setup runs', () => {
|
||||
const f = fixture({ jq: false });
|
||||
const r = run(f);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('preflight: jq not found');
|
||||
expect(r.stderr).toContain('(INSTALL_PREFLIGHT_FAILED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-preflight-failed)');
|
||||
expect(f.calls().some(l => l.startsWith('setup '))).toBe(false);
|
||||
});
|
||||
|
||||
test('Bun pin conflict: an exact project pin below the minimum stops with both constraints and installs nothing', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
const project = path.join(f.base, 'project');
|
||||
write(path.join(project, 'package.json'), '{ "engines": { "bun": "1.3.14" } }\n');
|
||||
sh('git init -q', project);
|
||||
const r = run(f, [], {}, project);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('Bun conflict: gstack needs >=1.4.2');
|
||||
expect(r.stderr).toContain(`${project}/package.json pins exactly 1.3.14`);
|
||||
expect(r.stderr).toContain('(RUNTIME_PIN_CONFLICT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-runtime-conflict)');
|
||||
expect(f.calls().some(l => l.startsWith('curl ') || l.startsWith('setup '))).toBe(false);
|
||||
// --project overrides the cwd's repository; a range the minimum satisfies agrees.
|
||||
write(path.join(project, 'package.json'), '{ "engines": { "bun": ">=1.4.0" } }\n');
|
||||
const agree = run(fixture(), ['--project', project]);
|
||||
expect(agree.status, agree.stdout + agree.stderr).toBe(0);
|
||||
expect(agree.stdout).toContain('project pin >=1.4.0');
|
||||
const conflictRange = run(fixture({ bun: '1.4.2' }), ['--project', (write(path.join(project, 'package.json'), '{ "engines": { "bun": ">=2.0.0" } }\n'), project)]);
|
||||
expect(conflictRange.status).toBe(3);
|
||||
expect(conflictRange.stderr).toContain('pins >=2.0.0');
|
||||
});
|
||||
|
||||
test('Bun below the minimum: the exact release archive is downloaded and SHA-256 verified; a mismatch installs nothing', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
const zip = path.join(f.base, 'bad.zip');
|
||||
fs.mkdirSync(path.join(f.base, 'z/bun-linux-x64'), { recursive: true });
|
||||
script(path.join(f.base, 'z/bun-linux-x64/bun'), ['echo 1.4.2']);
|
||||
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-linux-x64`).status).toBe(0);
|
||||
const r = run(f, [], { STUB_CURL_ZIP: zip });
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('SHA-256 mismatch');
|
||||
expect(r.stderr).toContain('nothing installed');
|
||||
expect(f.calls().filter(l => l.startsWith('curl '))).toHaveLength(1);
|
||||
expect(f.calls()[0]).toContain('https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64');
|
||||
expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(false);
|
||||
expect(f.calls().some(l => l.startsWith('setup '))).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('Bun below the minimum: a verified archive installs into BUN_INSTALL and ./setup runs on the new Bun', () => {
|
||||
const f = fixture({ bun: '1.3.14' });
|
||||
const target = sh('grep -q avx2 /proc/cpuinfo && echo linux-x64 || echo linux-x64-baseline').stdout.trim();
|
||||
const zip = path.join(f.base, 'good.zip');
|
||||
fs.mkdirSync(path.join(f.base, `z/bun-${target}`), { recursive: true });
|
||||
script(path.join(f.base, `z/bun-${target}/bun`), ['echo 1.4.2']);
|
||||
expect(sh(`cd "${f.base}/z" && zip -qr "${zip}" bun-${target}`).status).toBe(0);
|
||||
const sha = sh(`sha256sum "${zip}" | cut -d' ' -f1`).stdout.trim();
|
||||
const versions = path.join(f.root, 'bin/gstack-bun-version.sh');
|
||||
fs.writeFileSync(versions, fs.readFileSync(versions, 'utf8').replace(new RegExp(`(\\s*${target}\\) echo )[0-9a-f]{64}`), `$1${sha}`));
|
||||
const r = run(f, ['--for', 'autoplan'], { STUB_CURL_ZIP: zip });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('installing bun 1.4.2 from its release archive');
|
||||
expect(r.stdout).toContain(`bun 1.4.2 installed at ${path.join(f.home, '.bun/bin/bun')}`);
|
||||
expect(fs.existsSync(path.join(f.home, '.bun/bin/bun'))).toBe(true);
|
||||
expect(f.calls().filter(l => l.startsWith('env '))).toEqual(Array(2).fill('env GSTACK_SKIP_PLAYWRIGHT= GSTACK_SKIP_FONTS= bun=1.4.2'));
|
||||
expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
|
||||
});
|
||||
|
||||
test('--revision: an existing checkout at another revision is reused, never pulled, and the trailer says so', () => {
|
||||
const f = fixture();
|
||||
const before = sh('git rev-parse HEAD', f.root).stdout.trim();
|
||||
const r = run(f, ['--revision', 'v9.9.9.9']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(lastLine(r.stdout)).toBe('gstack: ok 1.2.3 for=autoplan (requested v9.9.9.9; run gstack-upgrade to move)');
|
||||
expect(sh('git rev-parse HEAD', f.root).stdout.trim()).toBe(before);
|
||||
expect(run(f, ['--revision', '1.2.3']).stdout).not.toContain('requested');
|
||||
expect(lastLine(run(f, ['--revision', before]).stdout)).toBe('gstack: ok 1.2.3 for=autoplan');
|
||||
});
|
||||
|
||||
test('--root clones when the directory is absent and refuses a non-gstack directory', () => {
|
||||
const f = fixture();
|
||||
const clone = path.join(f.base, 'clone');
|
||||
const r = run(f, ['--root', clone], { GSTACK_CAPY_REPO: f.root });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`cloning ${f.root} into ${clone}`);
|
||||
expect(fs.existsSync(path.join(clone, 'setup'))).toBe(true);
|
||||
const occupied = path.join(f.base, 'occupied');
|
||||
write(path.join(occupied, 'README.md'), 'x');
|
||||
const refused = run(f, ['--root', occupied]);
|
||||
expect(refused.status).toBe(3);
|
||||
expect(refused.stderr).toContain('is not a gstack checkout');
|
||||
});
|
||||
});
|
||||
|
||||
function run(f: ReturnType<typeof fixture>, args: string[] = [], extra: Record<string, string> = {}, cwd?: string) {
|
||||
return f.run(args, extra, cwd);
|
||||
}
|
||||
+268
-1
@@ -26,6 +26,7 @@ const COPIED = [
|
||||
'bin/gstack-render-claude.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-hook-check', 'bin/gstack-config',
|
||||
'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks
|
||||
'bin/gstack-launch-probe.sh',
|
||||
'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts',
|
||||
];
|
||||
const bases: string[] = [];
|
||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
||||
@@ -92,7 +93,8 @@ function makeFixture(): Fixture {
|
||||
const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim();
|
||||
write(path.join(state, 'installs.tsv'),
|
||||
['claude', 'global', '-', path.join(home, '.claude/skills'), root, root, version, 'false', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n');
|
||||
script(path.join(stub, 'bun'), ['[ "$1" = --version ] && { echo "${STUB_BUN_VERSION:-1.4.2}"; exit 0; }', 'exit 0']);
|
||||
// The stub answers --version; anything else (the pins row runs bin/gstack-runtime-pins.ts) goes to the real bun.
|
||||
script(path.join(stub, 'bun'), ['[ "$1" = --version ] && { echo "${STUB_BUN_VERSION:-1.4.2}"; exit 0; }', `exec "${process.execPath}" "$@"`]);
|
||||
script(path.join(stub, 'codex'), ['[ "$1" = --version ] && { echo "codex-cli 0.130.0"; exit 0; }', 'echo "unexpected codex call: $*" >> "$DOCTOR_PROBE_LOG"', 'exit 9']);
|
||||
script(path.join(stub, 'claude'), ['[ "$1" = --version ] && { echo "2.1.292 (Claude Code)"; exit 0; }', 'exit 9']);
|
||||
const env: Record<string, string> = {
|
||||
@@ -434,3 +436,268 @@ describe('gstack-doctor launch and CSO rows', () => {
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// A1: `--check` is the one-line verdict a parent agent greps. Rows are
|
||||
// `PASS | FAIL | SKIP <id>` from lib/doctor-components.ts; the trailer is
|
||||
// `gstack: ok <revision> for=<what>` (exit 0) or `gstack: fail <ids>` (exit 1).
|
||||
// Pinned on tokens, ids, exit codes and the JSON shape, not prose.
|
||||
describe('gstack-doctor --check', () => {
|
||||
const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim();
|
||||
const row = (f: Fixture, args: string[], extra: Record<string, string> = {}) => {
|
||||
const r = doctor(f, ['--check', ...args], extra);
|
||||
const lines = r.out.split('\n');
|
||||
const rows = Object.fromEntries(lines.filter(l => /^(PASS|FAIL|SKIP) /.test(l)).map(l => {
|
||||
const m = l.match(/^(PASS|FAIL|SKIP) ([a-z-]+)(?:\s(.*))?$/)!;
|
||||
return [m[2], { status: m[1], rest: m[3] ?? '' }];
|
||||
}));
|
||||
const trailer = lines.filter(l => l.startsWith('gstack: ')).at(-1) ?? '';
|
||||
const required = lines.find(l => l.startsWith('required: '))?.slice(10).split(' ') ?? [];
|
||||
return { status: r.status, out: r.out, rows, trailer, required, trailers: lines.filter(l => l.startsWith('gstack: ')).length };
|
||||
};
|
||||
/** Register a Codex install from this checkout and render a review skill, with or without the Astra patch. */
|
||||
const installCodex = (f: Fixture, patch = true) => {
|
||||
const dest = path.join(f.home, '.codex/skills');
|
||||
fs.appendFileSync(path.join(f.state, 'installs.tsv'),
|
||||
['codex', 'global', '-', dest, path.join(dest, 'gstack'), f.root, VERSION, 'true', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n');
|
||||
fs.mkdirSync(path.join(dest, 'gstack'), { recursive: true });
|
||||
write(path.join(dest, 'gstack-review/SKILL.md'), `---\nname: gstack-review\n---\n# Review\n${patch ? '\n## Model-Specific Behavioral Patch (gpt-6-astra)\n' : ''}`);
|
||||
return dest;
|
||||
};
|
||||
const installPlaywrightStub = (f: Fixture, launches: boolean) => write(path.join(f.root, 'node_modules/playwright/index.js'),
|
||||
launches
|
||||
? 'module.exports.chromium = { launch: async () => ({ version: () => "141.0.1", close: async () => {}, newPage: async () => ({ setContent: async () => {}, title: async () => "gstack smoke test" }) }) };'
|
||||
: 'module.exports.chromium = { launch: async () => { throw new Error("browserType.launch: Executable doesn\'t exist"); } };');
|
||||
|
||||
test('healthy Claude + Codex installs: every required row PASS, exactly one trailer, exit 0, no paid probe', () => {
|
||||
const f = makeFixture();
|
||||
installCodex(f);
|
||||
const r = row(f, []);
|
||||
expect(r.status, r.out).toBe(0);
|
||||
expect(r.required).toEqual(['claude', 'codex', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision']);
|
||||
for (const id of r.required.filter(x => x !== 'pins')) expect(r.rows[id]?.status, `${id}\n${r.out}`).toBe('PASS');
|
||||
// The work dir is not a git repository, so there is no project to read pins from: SKIP with the reason, never silent.
|
||||
expect(r.rows.pins).toEqual({ status: 'SKIP', rest: expect.stringContaining('no project') });
|
||||
expect(r.rows.patch.status).toBe('PASS');
|
||||
expect(r.rows['codex-cli'].status).toBe('PASS');
|
||||
expect(r.rows.browser).toEqual({ status: 'SKIP', rest: '(lazy; gstack-browser-ensure installs on first use)' });
|
||||
expect(r.rows.cso.status).toBe('SKIP');
|
||||
expect(r.trailer).toBe(`gstack: ok ${VERSION} for=installed-hosts:claude,codex`);
|
||||
expect(r.trailers).toBe(1);
|
||||
expect(r.rows.runtime.rest).toContain('floor 1.3.3, supported minimum 1.4.2');
|
||||
expect(r.rows.privacy.rest).toBe('telemetry=off artifacts_sync=off codex_reviews=enabled update_check=true');
|
||||
expect(r.rows['state-root'].rest).toContain('durable=yes');
|
||||
expect(probeCalls(f)).not.toContain('probe-model');
|
||||
});
|
||||
|
||||
test('--for autoplan: healthy Capy machine with Claude subagents as the outside voice and no Codex CLI passes', () => {
|
||||
const f = makeFixture();
|
||||
installCodex(f);
|
||||
fs.rmSync(path.join(f.stub, 'codex'));
|
||||
const r = row(f, ['--for', 'autoplan']);
|
||||
expect(r.status, r.out).toBe(0);
|
||||
expect(r.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision']);
|
||||
expect(r.rows.patch).toEqual({ status: 'PASS', rest: 'gpt-6-astra (gstack-review/SKILL.md)' });
|
||||
expect(r.rows['codex-cli'].status).toBe('SKIP');
|
||||
expect(r.rows['codex-cli'].rest).toContain('optional');
|
||||
expect(r.trailer).toBe(`gstack: ok ${VERSION} for=autoplan`);
|
||||
// The same machine with a stale Codex render fails the patch row.
|
||||
installCodex(f, false);
|
||||
const noPatch = row(f, ['--for', 'autoplan']);
|
||||
expect(noPatch.status).toBe(1);
|
||||
expect(noPatch.rows.patch.status).toBe('FAIL');
|
||||
expect(noPatch.rows.patch.rest).toContain('fix: ');
|
||||
expect(noPatch.rows.patch.rest).toContain('(COMPONENT_MISSING; ');
|
||||
expect(noPatch.rows.patch.rest).toContain('--host codex --model');
|
||||
expect(noPatch.trailer).toBe('gstack: fail patch');
|
||||
});
|
||||
|
||||
test('an empty installed-host set is never ok: claude is required and FAILs', () => {
|
||||
const f = makeFixture();
|
||||
write(path.join(f.state, 'installs.tsv'), '');
|
||||
const r = row(f, []);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.required).toContain('claude');
|
||||
expect(r.rows.claude.status).toBe('FAIL');
|
||||
expect(r.rows.claude.rest).toContain(`fix: cd ${bashPath(f.root)} && ./setup --host claude --no-prefix`);
|
||||
expect(r.trailer).toBe('gstack: fail claude');
|
||||
expect(JSON.parse(doctor(f, ['--json']).out).for).toBe('installed-hosts:none(claude required)');
|
||||
});
|
||||
|
||||
test('wrong host: an install registered from another checkout does not count; --require names what is missing', () => {
|
||||
const f = makeFixture();
|
||||
const other = path.join(f.base, 'other');
|
||||
write(path.join(f.state, 'installs.tsv'), ['codex', 'global', '-', path.join(f.home, '.codex/skills'), other, other, VERSION, 'false', 'committed', 'x'].join('\t') + '\n');
|
||||
const r = row(f, ['--require', 'codex,patch']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.rows.codex.status).toBe('FAIL');
|
||||
expect(r.rows.patch.status).toBe('FAIL');
|
||||
expect(r.rows.claude.status).toBe('SKIP');
|
||||
expect(r.trailer).toBe('gstack: fail codex,patch');
|
||||
expect(JSON.parse(doctor(f, ['--json', '--require', 'codex,patch']).out).for).toBe('codex,patch');
|
||||
});
|
||||
|
||||
test('missing authentication: --for codex needs the CLI; absent, unauthenticated and present are told apart, --live probes once', () => {
|
||||
const f = makeFixture();
|
||||
installCodex(f);
|
||||
const unauthed = row(f, ['--for', 'codex'], { STUB_AUTH_FAIL: '1' });
|
||||
expect(unauthed.status).toBe(1);
|
||||
expect(unauthed.rows['codex-cli'].status).toBe('FAIL');
|
||||
expect(unauthed.rows['codex-cli'].rest).toContain('not authenticated');
|
||||
expect(unauthed.rows['codex-cli'].rest).toContain('codex login --with-api-key');
|
||||
expect(unauthed.trailer).toBe('gstack: fail codex-cli');
|
||||
|
||||
const authed = row(f, ['--for', 'codex']);
|
||||
expect(authed.status, authed.out).toBe(0);
|
||||
expect(authed.rows['codex-cli'].rest).toContain('paid probe not run; add --live');
|
||||
expect(probeCalls(f)).not.toContain('probe-model');
|
||||
|
||||
const live = row(f, ['--for', 'codex', '--live']);
|
||||
expect(live.status, live.out).toBe(0);
|
||||
expect(live.rows['codex-cli'].rest).toContain('live probe ok');
|
||||
expect(probeCalls(f).match(/probe-model exec/g)?.length).toBe(1);
|
||||
|
||||
fs.rmSync(path.join(f.stub, 'codex'));
|
||||
const absent = row(f, ['--for', 'codex']);
|
||||
expect(absent.rows['codex-cli'].status).toBe('FAIL');
|
||||
expect(absent.rows['codex-cli'].rest).toContain('npm install -g @openai/codex');
|
||||
});
|
||||
|
||||
test('runtime mismatch: the supported minimum (1.4.2) and the floor (1.3.3) both FAIL in --check', () => {
|
||||
const f = makeFixture();
|
||||
const untested = row(f, [], { STUB_BUN_VERSION: '1.3.14' });
|
||||
expect(untested.status).toBe(1);
|
||||
expect(untested.rows.runtime.status).toBe('FAIL');
|
||||
expect(untested.rows.runtime.rest).toContain('below the supported minimum 1.4.2');
|
||||
expect(untested.rows.runtime.rest).toContain('(RUNTIME_BELOW_MINIMUM; ');
|
||||
expect(untested.trailer).toBe('gstack: fail runtime');
|
||||
const old = row(f, [], { STUB_BUN_VERSION: '1.3.2' });
|
||||
expect(old.rows.runtime.rest).toContain('below the security floor 1.3.3');
|
||||
expect(old.trailer).toBe('gstack: fail runtime');
|
||||
});
|
||||
|
||||
test('bun removed from PATH: the doctor still reports, runtime FAILs, pins SKIPs with a reason, one trailer', () => {
|
||||
const f = makeFixture();
|
||||
fs.rmSync(path.join(f.stub, 'bun'));
|
||||
const r = row(f, []);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.rows.runtime.status).toBe('FAIL');
|
||||
expect(r.rows.runtime.rest).toContain('bun not found on PATH');
|
||||
expect(r.rows.pins).toEqual({ status: 'SKIP', rest: expect.stringContaining('no bun to evaluate the project pins') });
|
||||
expect(r.rows.claude.status).toBe('PASS');
|
||||
expect(r.trailer).toBe('gstack: fail runtime');
|
||||
expect(r.trailers).toBe(1);
|
||||
});
|
||||
|
||||
test('project pins: --project reads the target repository, a range mismatch FAILs with its source, a match PASSes', () => {
|
||||
const f = makeFixture();
|
||||
const project = path.join(f.base, 'project');
|
||||
write(path.join(project, 'package.json'), '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n');
|
||||
const bad = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14' });
|
||||
expect(bad.rows.pins.status).toBe('FAIL');
|
||||
expect(bad.rows.pins.rest).toContain('bun 1.3.14 outside engines.bun >=1.4.2 (package.json:3)');
|
||||
expect(bad.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; ');
|
||||
expect(bad.trailer).toBe('gstack: fail runtime,pins');
|
||||
const good = row(f, ['--project', project]);
|
||||
expect(good.rows.pins).toEqual({ status: 'PASS', rest: `${project}: bun >=1.4.2 (engines.bun, package.json:3)` });
|
||||
const none = row(f, ['--project', f.work]);
|
||||
expect(none.rows.pins.status).toBe('PASS');
|
||||
expect(none.rows.pins.rest).toContain('pins no runtime');
|
||||
const missing = row(f, ['--project', path.join(f.base, 'nope')]);
|
||||
expect(missing.rows.pins.status).toBe('SKIP');
|
||||
});
|
||||
|
||||
test('--revision: a matching VERSION passes, an unmet revision is never ok', () => {
|
||||
const f = makeFixture();
|
||||
const ok = row(f, ['--revision', VERSION]);
|
||||
expect(ok.status, ok.out).toBe(0);
|
||||
expect(ok.rows.revision.status).toBe('PASS');
|
||||
const unmet = row(f, ['--revision', 'v9.9.9.9']);
|
||||
expect(unmet.status).toBe(1);
|
||||
expect(unmet.rows.revision.status).toBe('FAIL');
|
||||
expect(unmet.rows.revision.rest).toContain(`installed ${VERSION}`);
|
||||
expect(unmet.rows.revision.rest).toContain('(requested v9.9.9.9)');
|
||||
expect(unmet.rows.revision.rest).toContain('(REVISION_UNMET; ');
|
||||
expect(unmet.trailer).toBe('gstack: fail revision');
|
||||
});
|
||||
|
||||
test('browser skills: the bundle and a Chromium launch plus render are required; planning skills never launch it', () => {
|
||||
const f = makeFixture();
|
||||
installCodex(f);
|
||||
installPlaywrightStub(f, false);
|
||||
const broken = row(f, ['--for', 'qa']);
|
||||
expect(broken.status).toBe(1);
|
||||
expect(broken.rows['browse-bundle'].status).toBe('PASS');
|
||||
expect(broken.rows.browser.status).toBe('FAIL');
|
||||
expect(broken.rows.browser.rest).toContain("Executable doesn't exist");
|
||||
expect(broken.rows.browser.rest).toContain(`fix: ${bashPath(f.root)}/bin/gstack-browser-ensure (BROWSER_UNAVAILABLE; `);
|
||||
expect(broken.trailer).toBe('gstack: fail browser');
|
||||
installPlaywrightStub(f, true);
|
||||
const ok = row(f, ['--for', 'qa']);
|
||||
expect(ok.status, ok.out).toBe(0);
|
||||
expect(ok.rows.browser).toEqual({ status: 'PASS', rest: 'Chromium 141.0.1 launched and rendered (node)' });
|
||||
fs.rmSync(path.join(f.root, BROWSE_BIN));
|
||||
const noBundle = row(f, ['--for', 'browse']);
|
||||
expect(noBundle.rows['browse-bundle'].status).toBe('FAIL');
|
||||
expect(noBundle.rows['browse-bundle'].rest).toContain('fix: cd ');
|
||||
installPlaywrightStub(f, false);
|
||||
expect(row(f, ['--for', 'autoplan']).rows.browser.status).toBe('SKIP');
|
||||
});
|
||||
|
||||
test('cso: optional by default (SKIP, never silent), required by --for cso', () => {
|
||||
const f = makeFixture();
|
||||
expect(row(f, []).rows.cso).toEqual({ status: 'SKIP', rest: '(optional; toolchain absent or helper not built)' });
|
||||
const r = row(f, ['--for', 'cso']);
|
||||
expect(r.rows.cso.status).toBe('FAIL');
|
||||
expect(r.trailer).toBe('gstack: fail cso');
|
||||
for (const name of ['gstack-cso-launcher', 'gstack-cso-core']) write(path.join(f.root, 'bin', name + EXE), '#!/bin/sh\necho cso\n', 0o755);
|
||||
expect(row(f, ['--for', 'cso']).rows.cso.status).toBe('PASS');
|
||||
});
|
||||
|
||||
test('state root: GSTACK_EPHEMERAL=1 reports durable=no in --check (PASS) and warns in the dashboard', () => {
|
||||
const f = makeFixture();
|
||||
const r = row(f, [], { GSTACK_EPHEMERAL: '1' });
|
||||
expect(r.rows['state-root'].status).toBe('PASS');
|
||||
expect(r.rows['state-root'].rest).toContain(`${f.state} durable=no`);
|
||||
expect(r.rows['state-root'].rest).toContain('GSTACK_EPHEMERAL=1');
|
||||
expect(r.status, r.out).toBe(0);
|
||||
const dash = doctor(f, [], { GSTACK_EPHEMERAL: '1' });
|
||||
expect(dash.row('state root').state).toBe('warn');
|
||||
expect(dash.row('state root').detail).toContain('durable=no');
|
||||
expect(dash.row('state root').fix).toContain('GSTACK_STATE_ROOT');
|
||||
expect(doctor(f).row('state root').detail).toContain('durable=yes');
|
||||
});
|
||||
|
||||
test('--json: the same rows as objects keyed by the component ids, ok/failed/required and the trailer', () => {
|
||||
const f = makeFixture();
|
||||
installCodex(f);
|
||||
const r = doctor(f, ['--json', '--for', 'autoplan'], { STUB_BUN_VERSION: '1.3.14' });
|
||||
expect(r.status).toBe(1);
|
||||
const json = JSON.parse(r.out.trim().split('\n').at(-1)!);
|
||||
expect(json.schema_version).toBe(1);
|
||||
expect(json.revision).toBe(VERSION);
|
||||
expect(json.for).toBe('autoplan');
|
||||
expect(json.ok).toBe(false);
|
||||
expect(json.failed).toEqual(['runtime']);
|
||||
expect(json.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision']);
|
||||
expect(json.rows.map((x: { id: string }) => x.id)).toEqual(['claude', 'codex', 'patch', 'codex-cli', 'browse-bundle', 'browser', 'cso', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision']);
|
||||
const runtime = json.rows.find((x: { id: string }) => x.id === 'runtime');
|
||||
expect(runtime.status).toBe('FAIL');
|
||||
expect(runtime.code).toBe('RUNTIME_BELOW_MINIMUM');
|
||||
expect(runtime.anchor).toBe('doctor-runtime-below-minimum');
|
||||
expect(runtime.fix).toContain('gstack-capy-install');
|
||||
expect(json.trailer).toBe('gstack: fail runtime');
|
||||
expect(r.out.trim().split('\n')).toHaveLength(1);
|
||||
const ok = JSON.parse(doctor(f, ['--json']).out.trim());
|
||||
expect(ok.ok).toBe(true);
|
||||
expect(ok.trailer).toBe(`gstack: ok ${VERSION} for=installed-hosts:claude,codex`);
|
||||
});
|
||||
|
||||
test('usage: --for with --require, an unknown skill or component, and a flag without a value exit 2', () => {
|
||||
const f = makeFixture();
|
||||
expect(doctor(f, ['--check', '--for', 'autoplan', '--require', 'claude']).status).toBe(2);
|
||||
expect(doctor(f, ['--for', 'no-such-skill']).status).toBe(2);
|
||||
expect(doctor(f, ['--require', 'claude,bogus']).status).toBe(2);
|
||||
expect(doctor(f, ['--check', '--project']).status).toBe(2);
|
||||
expect(doctor(f, ['--help']).out).toContain('--check');
|
||||
});
|
||||
});
|
||||
@@ -37,16 +37,27 @@ const LATER_WAVE_EDITS: Array<[string, string]> = [
|
||||
["Reply in the language of the user's latest message unless asked otherwise. Code, commands, paths, identifiers and quoted output stay verbatim.\n\n", ''],
|
||||
];
|
||||
|
||||
// The honest-install wave (lazy Chromium) made the fallback probe call
|
||||
// gstack-browser-ensure, explained it in one sentence inside the entrypoint
|
||||
// (two lines longer), and pinned the Bun install hint by positional argument.
|
||||
const LATER_ENTRYPOINT_BLOCK = /`gstack-browser-ensure` installs Chromium on first use[^\n]*\n\n/;
|
||||
const LATER_ENTRYPOINT_EDITS: Array<[string, string]> = [
|
||||
['if [ -x "$B" ] && "${B%/browse/*}/bin/gstack-browser-ensure"; then', 'if [ -x "$B" ]; then'],
|
||||
['bash "$tmpfile" "bun-v$BUN_VERSION"', 'BUN_VERSION="$BUN_VERSION" bash "$tmpfile"'],
|
||||
];
|
||||
|
||||
export function approvedCookieWorkflowSource(source: string): string {
|
||||
let removedLines = 0;
|
||||
let removedEntrypointLines = 0;
|
||||
let historical = source
|
||||
.replace('sha256sum < "$tmpfile" | awk \'{print $(1)}\'', 'sha256sum "$tmpfile" | awk \'{print $1}\'')
|
||||
.replace('shasum -a 256 < "$tmpfile" | awk \'{print $(1)}\'', 'shasum -a 256 "$tmpfile" | awk \'{print $1}\'')
|
||||
.replace(LATER_BROWSER_BLOCK, block => { removedLines = block.split('\n').length - 1; return ''; });
|
||||
for (const [later, earlier] of [...LATER_BROWSER_EDITS, ...LATER_PREAMBLE_EDITS, ...LATER_WAVE_EDITS]) historical = historical.replace(later, earlier);
|
||||
.replace(LATER_BROWSER_BLOCK, block => { removedLines = block.split('\n').length - 1; return ''; })
|
||||
.replace(LATER_ENTRYPOINT_BLOCK, block => { removedEntrypointLines = block.split('\n').length - 1; return ''; });
|
||||
for (const [later, earlier] of [...LATER_BROWSER_EDITS, ...LATER_PREAMBLE_EDITS, ...LATER_WAVE_EDITS, ...LATER_ENTRYPOINT_EDITS]) historical = historical.replace(later, earlier);
|
||||
return historical
|
||||
.replace(/(--- BEGIN FILE "BROWSER\.md" \(lines \d+-)(\d+)(; section\) ---)/, (_, head, end, tail) => `${head}${Number(end) - removedLines}${tail}`)
|
||||
.replace(/(--- BEGIN FILE "setup-browser-cookies\/SKILL\.md" \(lines )(\d+)-(\d+)(; entrypoint\) ---)/, (_, head, start, end, tail) => `${head}${APPROVED_ENTRYPOINT_START}-${APPROVED_ENTRYPOINT_START + Number(end) - Number(start)}${tail}`);
|
||||
.replace(/(--- BEGIN FILE "setup-browser-cookies\/SKILL\.md" \(lines )(\d+)-(\d+)(; entrypoint\) ---)/, (_, head, start, end, tail) => `${head}${APPROVED_ENTRYPOINT_START}-${APPROVED_ENTRYPOINT_START + Number(end) - Number(start) - removedEntrypointLines}${tail}`);
|
||||
}
|
||||
|
||||
export function manualReviewFixture(root = resolve(import.meta.dir, '../..')): EvalTestEntry {
|
||||
|
||||
@@ -642,7 +642,7 @@ Recommendation: Fix the \`!n\` guard at scale.ts:3 because it rejects the docume
|
||||
...(caller === 'review' ? ['review/sections/adversarial.md'] : []),
|
||||
].map(file => fs.readFileSync(path.join(QA_CALLER_ROOT, file), 'utf8'))];
|
||||
const workflowCommands = sources.flatMap(source => [...source.matchAll(/```bash\n([\s\S]*?)\n```/g)]
|
||||
.map(match => match[1].replaceAll('~/.claude/skills/gstack', runtime).replaceAll('<base>', 'main').trim()));
|
||||
.map(match => match[1].replaceAll('~/.claude/skills/gstack', runtime).replaceAll('<base>', 'main').replaceAll('<plan-path>', 'PLAN.md').trim()));
|
||||
if (caller === 'review') {
|
||||
const native = fs.readFileSync(path.join(QA_CALLER_ROOT, 'review/sections/adversarial.md'), 'utf8');
|
||||
for (const match of native.matchAll(/`([^`\n]+)`/g)) {
|
||||
@@ -697,7 +697,7 @@ export function callerReviewRecordTemplate(fixture: Pick<QaCallerFixture, 'calle
|
||||
|
||||
export function qaCallerSessionOptions(fixture: QaCallerFixture, runId: string): Parameters<typeof runSkillTest>[0] {
|
||||
return {
|
||||
prompt: `Load gstack's /${fixture.caller} supplied parent phase from caller-${fixture.caller}.md and resume it on the selected working-tree diff against origin/main. This excerpt comes from ${fixture.runtime}/${fixture.caller}/SKILL.md; resolve installed-relative references there, not from the excerpt file or product directory. That path identifies the asset base, not another entrypoint: do not read or invoke the full parent SKILL.md or rerun its preamble. Earlier preamble/branch/base setup is complete; use the existing local origin/main ref without fetch. Earlier-phase asset locators are ${fixture.runtime}/review/checklist.md and ${fixture.runtime}/qa/templates/functional-report-template.md. Read those files directly when referenced; recursive Glob does not follow the installed asset symlinks. Cross-project learnings are configured off in this owned fixture. ${fixture.reviewStart ? `The actual review-start helper already captured REVIEW_START=${fixture.reviewStart} for this unchanged core pass; retain that token. ` : ''} This fixture evaluates only the supplied parent phase, not later publication stages. The fixture owner recorded these read-only observations when this phase began; they are current until an input changes, so use them instead of re-running those commands, and recheck freshness before completion outputs:\n${fixture.startObservations}\n Read README.md for the project contract and commands. Use diagnostic-client commands such as \`bun scripts/probe.ts <literal>\` for exploratory discoveries and their checkpoint evidence. A required \`bun run test\` is separate suite verification: report it as verification, never as a diagnostic observation or checkpoint anchor/target. Use the production evidence helper to publish each diagnostic checkpoint as \`reports/exploration-NNN.json\`, not inside a nested directory; do not transcribe its observed payload. ${fixture.caseId === 'ship-exploratory-plan-checks' ? 'The previously discovered plan is PLAN.md.' : 'No plan file was found.'} There is no remote service and no release publication is authorized. There is no interactive approver; do not invent answers or permission. Keep normal parent decision gates. Before the completion report and each completed:true review record, read HANDOFF.md and reports/HANDOFF.md if present for any concurrent collaborator update, await the results, and compare evidence with current inputs. If a probe's snapshot differs from an earlier probe's, read reports/HANDOFF.md first; it names any collaborator change. A gstack-review-log completed:true record is a completion, not preliminary bookkeeping; a later handoff read cannot validate an earlier completion.\n\nDeadline bookkeeping additionally permits \`bun ${fixture.runtime}/bin/gstack-qa-deadline start ${fixture.cwd}/reports/deadline.json SECONDS [EARLIER_UTC]\`, \`bun ${fixture.runtime}/bin/gstack-qa-deadline status ${fixture.cwd}/reports/deadline.json\`, and \`bun ${fixture.runtime}/bin/gstack-qa-deadline run ${fixture.cwd}/reports/deadline.json -- bun scripts/probe.ts [literal]\`. These are closed literal forms: SECONDS must be positive and at most 300, EARLIER_UTC is the optional caller absolute deadline: use the section clock's Hard deadline UTC, never its Runner entry UTC, reserve-start time or a clock-read time. The child is only the existing diagnostic client with zero or one literal argument. Resolve these exact helper and state paths; do not use variables, another helper, another state file, nested wrappers, scripts, operators or substitutions. Only this helper may create or change reports/deadline.json and its .qa-deadline- temporary files; never use Write/Edit/MultiEdit on those paths. Record the full outer run command in checkpoints and evidence; keep the unchanged child JSON as observed, separate from prefixed guard diagnostics. A completed expired guard-run is not a probe or a pass: retain its unused checkpoint, report not-run coverage and do not restart the deadline. Keep the 12-probe smoke limit. Required suites and explicit plan checks are outside the bounded smoke budget, not permission to reset it.\n\nFunctional evidence uses the same production helper and existing diagnostic client: \`bun ${fixture.runtime}/bin/gstack-qa-evidence capture ${fixture.cwd}/reports NNN --public --deadline ${fixture.cwd}/reports/deadline.json -- bun scripts/probe.ts [literal]\`. These diagnostic receipts are declared public/synthetic, so --public is approved; a fresh three-digit ID is required each time. Explicit plan probes outside the smoke budget may replace --deadline with --timeout-ms 10000; this does not reset or bypass the smoke deadline. After the first capture, put causal intent in the next capture itself by adding \`--after PREV --hypothesis 'causal hypothesis'\` before \`--\` (PREV = the most recent complete capture ID); the helper publishes reports/exploration-NNN.json linking capture PREV's observation to that probe before running it, so one call is both checkpoi
Line truncated
|
||||
prompt: `Load gstack's /${fixture.caller} supplied parent phase from caller-${fixture.caller}.md and resume it on the selected working-tree diff against origin/main. This excerpt comes from ${fixture.runtime}/${fixture.caller}/SKILL.md; resolve installed-relative references there, not from the excerpt file or product directory. That path identifies the asset base, not another entrypoint: do not read or invoke the full parent SKILL.md or rerun its preamble. Earlier preamble/branch/base setup is complete; use the existing local origin/main ref without fetch. Earlier-phase asset locators are ${fixture.runtime}/review/checklist.md and ${fixture.runtime}/qa/templates/functional-report-template.md. Read those files directly when referenced; recursive Glob does not follow the installed asset symlinks. Cross-project learnings are configured off in this owned fixture. ${fixture.reviewStart ? `The actual review-start helper already captured REVIEW_START=${fixture.reviewStart} for this unchanged core pass; retain that token. ` : ''} This fixture evaluates only the supplied parent phase, not later publication stages. The fixture owner recorded these read-only observations when this phase began; they are current until an input changes, so use them instead of re-running those commands, and recheck freshness before completion outputs:\n${fixture.startObservations}\n Read README.md for the project contract and commands. Use diagnostic-client commands such as \`bun scripts/probe.ts <literal>\` for exploratory discoveries and their checkpoint evidence. A required \`bun run test\` is separate suite verification: report it as verification, never as a diagnostic observation or checkpoint anchor/target. Use the production evidence helper to publish each diagnostic checkpoint as \`reports/exploration-NNN.json\`, not inside a nested directory; do not transcribe its observed payload. ${fixture.caseId === 'ship-exploratory-plan-checks' ? 'The previously discovered plan is PLAN.md.' : 'No plan file was found.'} There is no remote service and no release publication is authorized. There is no interactive approver; do not invent answers or permission. Keep normal parent decision gates. Before the completion report and each completed:true review record, read HANDOFF.md and reports/HANDOFF.md if present for any concurrent collaborator update, await the results, and compare evidence with current inputs. If a probe's snapshot differs from an earlier probe's, read reports/HANDOFF.md first; it names any collaborator change. A gstack-review-log completed:true record is a completion, not preliminary bookkeeping; a later handoff read cannot validate an earlier completion.\n\nDeadline bookkeeping additionally permits \`bun ${fixture.runtime}/bin/gstack-qa-deadline start ${fixture.cwd}/reports/deadline.json SECONDS [EARLIER_UTC]\`, \`bun ${fixture.runtime}/bin/gstack-qa-deadline status ${fixture.cwd}/reports/deadline.json\`, and \`bun ${fixture.runtime}/bin/gstack-qa-deadline run ${fixture.cwd}/reports/deadline.json -- bun scripts/probe.ts [literal]\`. These are closed literal forms: SECONDS must be positive and at most 300, EARLIER_UTC is the optional caller absolute deadline: use the section clock's Hard deadline UTC, never its Runner entry UTC, reserve-start time or a clock-read time. The child is only the existing diagnostic client with zero or one literal argument. Resolve these exact helper and state paths; do not use variables, another helper, another state file, nested wrappers, scripts, operators or substitutions. Only this helper may create or change reports/deadline.json and its .qa-deadline- temporary files; never use Write/Edit/MultiEdit on those paths. Record the full outer run command in checkpoints and evidence; keep the unchanged child JSON as observed, separate from prefixed guard diagnostics. A completed expired guard-run is not a probe or a pass: retain its unused checkpoint, report not-run coverage and do not restart the deadline. Keep the 12-probe smoke limit. Required suites and explicit plan checks are outside the bounded smoke budget, not permission to reset it.\n\nFunctional evidence uses the same production helper and existing diagnostic client: \`bun ${fixture.runtime}/bin/gstack-qa-evidence capture ${fixture.cwd}/reports NNN --public --deadline ${fixture.cwd}/reports/deadline.json -- bun scripts/probe.ts [literal]\`. These diagnostic receipts are declared public/synthetic, so --public is approved; a fresh three-digit ID is required each time. Explicit plan probes outside the smoke budget may replace --deadline with --timeout-ms 10000; this does not reset or bypass the smoke deadline. After the first capture, put causal intent in the next capture itself by adding \`--after PREV --hypothesis 'causal hypothesis'\` before \`--\` (PREV = the most recent complete capture ID); the helper publishes reports/exploration-NNN.json linking capture PREV's observation to that probe before running it, so one call is both checkpoi
Line truncated
|
||||
appendSystemPrompt: `Caller execution scheduling (fixture contract):
|
||||
This session has at most 25 assistant turns, including required verification and final artifacts. The command boundary applies to each Bash call, not to the number of independent tool calls in an assistant turn.
|
||||
After required clock and approval prerequisites settle, issue independent source Reads and read-only discovery together as separate native tool calls once their paths and inputs are known. For example, after the first clock read, one response can Read the caller excerpt, README.md, every product file and the referenced checklist and templates. Wait for their results before decisions that depend on them.
|
||||
|
||||
@@ -204,7 +204,9 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
|
||||
const qa = join(src, '.agents/skills/gstack-qa/sections');
|
||||
rmSync(join(qa, readdirSync(qa).find(n => n.endsWith('.md'))!));
|
||||
const status = runSetup(f, join(src, 'setup'), ['--status']);
|
||||
expect(status.status).toBe(0);
|
||||
// A1: broken section links make --status exit 1 instead of always 0.
|
||||
expect(status.status).toBe(1);
|
||||
expect(status.stderr).toContain('broken section links');
|
||||
expect(status.stdout).toMatch(/codex global: router is a symlink, which Codex skips\. Fix: cd \S+ && \.\/setup --host codex; section links: [1-9]\d* of \d+ broken \(first: [^)]*\)\. Fix: cd /);
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -730,6 +730,21 @@ describe('generated actual parent paths', () => {
|
||||
expect(() => callerExcerpt('START START END', 'START', 'END')).toThrow('boundary');
|
||||
});
|
||||
|
||||
test('the ship fixture admits the plan-completion acceptance block with the discovered plan path substituted', () => {
|
||||
const fixture = createQaCallerFixture('ship-exploratory-plan-checks', { installRuntime: false });
|
||||
try {
|
||||
const block = fixture.workflowCommands.find(command => command.startsWith('PLAN_FILE="PLAN.md"\n_REL=$(git ls-files --full-name --error-unmatch'));
|
||||
expect(block).toBeDefined();
|
||||
expect(block).not.toContain('<plan-path>');
|
||||
expect(block).not.toContain('<base>');
|
||||
expect(qaCallerCommandAllowed(block!, fixture.workflowCommands)).toBe(true);
|
||||
expect(qaCallerCommandAllowed(block!.replace('PLAN.md', 'OTHER.md'), fixture.workflowCommands)).toBe(false);
|
||||
expect(qaCallerSessionOptions(fixture, 'free-control').prompt).toContain('PLAN.md for <plan-path>');
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the review fixture admits only the actual native read-only diff fragments', () => {
|
||||
const fixture = createQaCallerFixture('review-exploratory-small-cli', { installRuntime: false });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { GATE_OUTCOMES, gateOutcomeLine, type GateReason } from '../lib/gate-outcomes';
|
||||
import { RESULT_CODES, resultLine } from '../lib/result-codes';
|
||||
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf8');
|
||||
@@ -28,6 +28,8 @@ describe('docs/troubleshooting.md anchors', () => {
|
||||
expect(anchors.has(row.anchor)).toBe(true);
|
||||
const after = doc.slice(doc.indexOf(`<a id="${row.anchor}"></a>`)).split('\n')[1] ?? '';
|
||||
expect(after).toMatch(/^#{2,4} /);
|
||||
expect(row.fix.trim().length).toBeGreaterThan(0);
|
||||
expect(resultCodeSuffix(code as keyof typeof RESULT_CODES)).toBe(`(${code}; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#${row.anchor})`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user