mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
* test(free): split files over the 60 s seed ceiling and the Windows doctor file Eight free test files the fresh recordings put over the seed ratchet's 60 s ceiling, and test/gstack-doctor.test.ts (343 s on Windows), are split by describe block or test with shared helpers moved to test/helpers/. Test bodies are unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(todos): note the GSTA-151 seed-ceiling splits Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(free): reseed both duration seeds at this head Recorded by free-durations-record.yml (open_pr=false) run 40818261447 atec6faf1: 1,416 Linux files on ubicloud-standard-8 and the 795 curated Windows files on windows-latest. test/setup-codex-scope.test.ts fell to 49 s and leaves the seed allowlist. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog entry for the GSTA-151 seed-ceiling splits (unreleased) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(free): split test/ubicloud-runner.test.ts over the 60 s seed ceiling; keep setup-codex-scope allowlisted The reseed at the main merge (free-durations-record run 41226501374,221320e) put test/ubicloud-runner.test.ts, which main added after this branch's first reseed, at 127 s and test/setup-codex-scope.test.ts back at 66 s. The fake-API cases move into four parts by describe topic with their shared fake VM in test/helpers/ubi-runner-fake-vm.ts (46 tests before and after). The allowlist keeps main's setup-codex-scope entry. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(free): reseed both duration seeds at the main merge; changelog fragment Recorded by free-durations-record.yml (open_pr=false) run 41229924108 at4f5bd2d: 1,470 Linux files on ubicloud-standard-8 and 822 curated Windows files. The Windows recorder was red on browse/test/cookie-import-native-job.test.ts (known Edge leak) and test/hook-scripts.test.ts, plus test/free-seed-ratchet.test.ts on both OSes against the pre-reseed seed; their durations are kept. The CHANGELOG entry moves to changelog.d/ now that main runs on fragments. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: correct the seed-ceiling TODO and the ubicloud-runner test paths after the split 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>
374 lines
24 KiB
TypeScript
374 lines
24 KiB
TypeScript
// Split from test/gstack-doctor.test.ts: the `gstack-doctor --check` verdict rows, trailer and JSON.
|
|
import { describe, expect, test } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { useDoctorCleanup, REPO, BROWSE_BIN, EXE, write, script, bashPath, probeCalls, makeFixture, doctor, type Fixture } from './helpers/doctor-fixture';
|
|
|
|
useDoctorCleanup();
|
|
|
|
// 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|WARN) /.test(l)).map(l => {
|
|
const m = l.match(/^(PASS|FAIL|SKIP|WARN) ([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', 'floor', 'wrapper']);
|
|
for (const id of r.required.filter(x => x !== 'pins' && x !== 'floor' && x !== 'wrapper')) 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 or a floor from: SKIP with the reason, never silent.
|
|
expect(r.rows.pins).toEqual({ status: 'SKIP', rest: expect.stringContaining('no project') });
|
|
expect(r.rows.floor).toEqual({ status: 'SKIP', rest: expect.stringContaining('no project to read .gstack/ship-policy.json from') });
|
|
// No ~/.capy/drive skill copy and no active wrapper: not a Capy machine.
|
|
expect(r.rows.wrapper).toEqual({ status: 'SKIP', rest: expect.stringContaining('not a Capy machine') });
|
|
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');
|
|
});
|
|
|
|
// Value: protects=the --check state-root row names the variable that selected the root, after earlier rows read config;
|
|
// fails_when=an in-process config read reselects the root from the doctor's own GSTACK_STATE_ROOT;
|
|
// why_new=every other fixture sets GSTACK_STATE_ROOT itself, so a wrong "selected by" still reads GSTACK_STATE_ROOT; seam=none;
|
|
// no_claim=CLAUDE_PLUGIN_DATA and default selection (GSTACK_HOME stands in for every selector other than GSTACK_STATE_ROOT)
|
|
test('the state-root row names GSTACK_HOME when GSTACK_HOME selected the root, in text and JSON', () => {
|
|
const f = makeFixture();
|
|
installCodex(f);
|
|
const byHome = { GSTACK_STATE_ROOT: '', GSTACK_HOME: f.state };
|
|
const r = row(f, [], byHome);
|
|
expect(r.rows['state-root'].rest, r.out).toContain('(selected by GSTACK_HOME;');
|
|
const json = JSON.parse(doctor(f, ['--json'], byHome).out);
|
|
expect(json.rows.find((x: { id: string }) => x.id === 'state-root').detail).toContain('(selected by GSTACK_HOME;');
|
|
});
|
|
|
|
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', 'floor', 'wrapper']);
|
|
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('--for autoplan: a hoisted Codex render carries the patch in its shared sections/preamble.md', () => {
|
|
const f = makeFixture();
|
|
const dest = installCodex(f, false);
|
|
write(path.join(dest, 'gstack-review/sections/preamble.md'), '# gstack shared preamble 0123456789ab\n\n## Model-Specific Behavioral Patch (gpt-6-astra)\n');
|
|
const r = row(f, ['--for', 'autoplan']);
|
|
expect(r.rows.patch, r.out).toEqual({ status: 'PASS', rest: 'gpt-6-astra (gstack-review/sections/preamble.md)' });
|
|
});
|
|
|
|
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('project pins on Capy: the PROJECT_PIN_MISMATCH fix is the exact bootstrapper command, which installs a verified pinned Bun', () => {
|
|
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 wrapper = '/drive/skills/gstack/scripts/install.sh';
|
|
const capy = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14', GSTACK_CAPY_WRAPPER_PATH: wrapper });
|
|
expect(capy.rows.pins.status).toBe('FAIL');
|
|
expect(capy.rows.pins.rest).toContain(`fix: bash ${wrapper} (installs the project's pinned Bun when it is a verified version: 1.4.2 1.4.3 1.4.4; otherwise it names the conflict), or change the pin in ${project}`);
|
|
expect(capy.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; ');
|
|
const forSkill = row(f, ['--project', project, '--for', 'review'], { STUB_BUN_VERSION: '1.3.14', GSTACK_CAPY_WRAPPER_PATH: wrapper });
|
|
expect(forSkill.rows.pins.rest).toContain(`fix: bash ${wrapper} --for review (installs`);
|
|
const plain = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14' });
|
|
expect(plain.rows.pins.rest).toContain('fix: install the pinned version for this platform');
|
|
expect(plain.rows.pins.rest).not.toContain(wrapper);
|
|
});
|
|
|
|
test('project pins: a Node-only pin mismatch is a WARN (the trailer stays ok) that names the node it read and a fix that installs the pinned Node', () => {
|
|
const f = makeFixture();
|
|
const project = path.join(f.base, 'project');
|
|
write(path.join(project, '.github/workflows/native-locks.yml'), 'jobs:\n locks:\n steps:\n - uses: actions/setup-node@v4\n with:\n node-version: 24.18.0\n');
|
|
script(path.join(f.stub, 'node'), ['echo v24.21.0']);
|
|
const wrapper = '/drive/skills/gstack/scripts/install.sh';
|
|
const capy = row(f, ['--project', project, '--for', 'health'], { GSTACK_CAPY_WRAPPER_PATH: wrapper });
|
|
expect(capy.rows.pins.status).toBe('WARN');
|
|
expect(capy.rows.pins.rest).toContain(`node 24.21.0 outside CI 24.18.0 (.github/workflows/native-locks.yml:6); node read from ${bashPath(f.stub)}/node (node pin only; bun pins pass)`);
|
|
expect(capy.rows.pins.rest).toContain(`fix: bash ${wrapper} --for health (installs the project's pinned Node 24.18.0 into gstack's runtime store and launches it through the GSTACK_LAUNCH PATH), or change the pin in ${project}`);
|
|
expect(capy.rows.pins.rest).toContain('(PROJECT_NODE_PIN_UNMET; ');
|
|
expect(capy.trailer).toBe(`gstack: ok ${VERSION} for=health`);
|
|
const plain = row(f, ['--project', project, '--for', 'health']);
|
|
expect(plain.rows.pins.rest).toContain(`fix: nvm install 24.18.0 and put its bin directory first on PATH`);
|
|
// A Bun pin that also fails keeps the FAIL and its code: only a Node-only gap is advisory.
|
|
write(path.join(project, 'package.json'), '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n');
|
|
const both = row(f, ['--project', project, '--for', 'health'], { STUB_BUN_VERSION: '1.3.14' });
|
|
expect(both.rows.pins.status).toBe('FAIL');
|
|
expect(both.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; ');
|
|
fs.rmSync(path.join(project, 'package.json'));
|
|
script(path.join(f.stub, 'node'), ['echo v24.18.0']);
|
|
expect(row(f, ['--project', project, '--for', 'health']).rows.pins.status).toBe('PASS');
|
|
});
|
|
|
|
test('floor: min_gstack_version in the project\'s .gstack/ship-policy.json PASSes at or below the installed version, WARNs above it with the upgrade as the fix, and SKIPs when absent or null', () => {
|
|
const f = makeFixture();
|
|
const project = path.join(f.base, 'project');
|
|
fs.mkdirSync(project, { recursive: true });
|
|
const none = row(f, ['--project', project]);
|
|
expect(none.rows.floor).toEqual({ status: 'SKIP', rest: `(${project} declares none (.gstack/ship-policy.json absent; set min_gstack_version there to require a gstack version))` });
|
|
write(path.join(project, '.gstack/ship-policy.json'), '{\n "bump": "patch",\n "min_gstack_version": null\n}\n');
|
|
expect(row(f, ['--project', project]).rows.floor).toEqual({ status: 'SKIP', rest: `(${project}/.gstack/ship-policy.json sets no min_gstack_version (null or absent))` });
|
|
// A present value that is not a dotted version is named as such, never read as "absent"; the shell never evaluates it.
|
|
const marker = path.join(project, 'evaluated-marker');
|
|
write(path.join(project, '.gstack/ship-policy.json'), `{\n "min_gstack_version": "1.2.3-rc1; $(touch ${marker})"\n}\n`);
|
|
expect(row(f, ['--project', project]).rows.floor).toEqual({ status: 'SKIP', rest: `(${project}/.gstack/ship-policy.json sets min_gstack_version to something other than a plain dotted version)` });
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
write(path.join(project, '.gstack/ship-policy.json'), `{\n "min_gstack_version": "${VERSION}"\n}\n`);
|
|
const met = row(f, ['--project', project]);
|
|
expect(met.rows.floor).toEqual({ status: 'PASS', rest: `${VERSION} declared by ${project}/.gstack/ship-policy.json (installed ${VERSION})` });
|
|
// Valid JSON may put the value on the line after its field (found by Codex in /ship's adversarial review).
|
|
write(path.join(project, '.gstack/ship-policy.json'), `{\n "bump": "patch",\n "min_gstack_version":\n "${VERSION}"\n}\n`);
|
|
expect(row(f, ['--project', project]).rows.floor.status).toBe('PASS');
|
|
// The first occurrence is the top-level field: a later nested one never lowers the floor (found by Codex).
|
|
write(path.join(project, '.gstack/ship-policy.json'), '{\n "min_gstack_version": "999.0.0.0",\n "$schema": {"min_gstack_version": "0.0.0"}\n}\n');
|
|
expect(row(f, ['--project', project]).rows.floor.status).toBe('WARN');
|
|
write(path.join(project, '.gstack/ship-policy.json'), '{\n "min_gstack_version": "999.0.0.0"\n}\n');
|
|
const wrapper = '/drive/skills/gstack/scripts/install.sh';
|
|
const unmet = row(f, ['--project', project, '--for', 'health'], { GSTACK_CAPY_WRAPPER_PATH: wrapper });
|
|
expect(unmet.status, unmet.out).toBe(0);
|
|
expect(unmet.rows.floor.status).toBe('WARN');
|
|
expect(unmet.rows.floor.rest).toContain(`installed ${VERSION} is below the floor 999.0.0.0 that ${project}/.gstack/ship-policy.json declares (min_gstack_version); fix: bash ${wrapper} --for health (fast-forwards a clean checkout to origin and reinstalls), or `);
|
|
expect(unmet.rows.floor.rest).toContain('(PROJECT_FLOOR_UNMET; ');
|
|
expect(unmet.trailer).toBe(`gstack: ok ${VERSION} for=health`);
|
|
const plain = row(f, ['--project', project]);
|
|
expect(plain.rows.floor.rest).toContain('fix: ');
|
|
expect(plain.rows.floor.rest).toContain('bin/gstack-upgrade (fast-forwards to the remote default branch');
|
|
expect(plain.rows.floor.rest).not.toContain(wrapper);
|
|
});
|
|
|
|
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', 'floor', 'wrapper']);
|
|
expect(json.rows.map((x: { id: string }) => x.id)).toEqual(['claude', 'codex', 'patch', 'codex-cli', 'browse-bundle', 'browser', 'cso', 'helpers', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']);
|
|
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');
|
|
});
|
|
});
|