mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
* feat: add surface-aware exploratory QA and ship documentation gates * test: preserve delegated QA setup authority after main integration * fix(qa): clarify exploration order and preserve report artifacts * test(qa): follow the shared setup reference directly * refactor(ship): make verification and recovery routes explicit * test(ship): align evidence and review guards with explicit routes * fix(workflows): clarify ship recovery and functional QA evidence * fix(workflows): clarify approval recovery and full QA coverage * refactor(workflows): order review transactions and clarify ship state * fix(ship): clarify final verification and fail closed at publication * fix(evals): attribute native atomic documentation writes * fix(ship): clarify recovery and documentation lifecycle guidance * fix(test): preserve observed native placeholder styling in CI * fix(codex): report watchdog timeouts without a process-exit race * Checkpoint functional QA implementation and workflow validation repairs * Fix documentation and shared-review fixture contracts * docs: clarify judge reuse and evaluation supervision * test: align review evidence and selected case contracts * test: verify append-only documentation checkpoints and recovery * fix: qualify QA workflows and CI validation repairs * fix: launch shared-libs fixture scripts on Windows * fix: qualify QA deadlines, fixture isolation, and shard cleanup * fix: preserve qualified QA and cancellation repairs * fix: enforce functional fixture authority and share strict event decoding * fix: retain free-test evidence and explain recovery * fix: reject malformed native evidence after decoder consolidation * test: use reliable capture for telemetry privacy filters * test: refresh measured quick coverage and document validation costs * Fix native fixture receipts and preserve VM validation evidence * Align negative judge controls with upstream clarity policy * Fix report-only QA preparation and public evidence handling * Clarify QA-only preparation and current-report preservation * Stream Ship quality judgments with an explicit 64k response contract * Validate compact judge reasoning locally with supported wire schema * Align functional QA fixture instructions with evidence acceptance * Bind native browser diagnostics to execution evidence and align review verdicts * Preserve native diagnostic line boundaries * Serialize functional QA evidence from native captures * Keep large QA evidence fixture payload out of Windows argv
219 lines
12 KiB
TypeScript
219 lines
12 KiB
TypeScript
import { expect, spyOn, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { stopQaOnlyBrowser } from './helpers/qa-only-cleanup';
|
|
import { isAgentRecordGone, isOurAgent, readAgentRecord, spawnTerminalAgent, stopAgentByRecord } from '../browse/src/terminal-agent-control';
|
|
import { readPidStartTime } from '../browse/src/xvfb';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const waitFor = async (check: () => boolean, timeout = 10_000) => {
|
|
const deadline = performance.now() + timeout;
|
|
while (!check()) {
|
|
if (performance.now() >= deadline) throw new Error('QA cleanup fixture did not become ready');
|
|
await Bun.sleep(25);
|
|
}
|
|
};
|
|
|
|
test('missing owned state never consults ambient state or bootstraps a daemon', async () => {
|
|
const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qa-clean-'));
|
|
const prior = process.env.BROWSE_STATE_FILE;
|
|
const ambient = path.join(dir, 'ambient.json');
|
|
fs.writeFileSync(ambient, 'must not read or mutate');
|
|
process.env.BROWSE_STATE_FILE = ambient;
|
|
try {
|
|
await stopQaOnlyBrowser(dir, 200);
|
|
expect(fs.readdirSync(dir)).toEqual(['ambient.json']);
|
|
expect(fs.readFileSync(ambient, 'utf8')).toBe('must not read or mutate');
|
|
} finally {
|
|
if (prior === undefined) delete process.env.BROWSE_STATE_FILE;
|
|
else process.env.BROWSE_STATE_FILE = prior;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('linked owned state cannot redirect cleanup to a sibling', async () => {
|
|
const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qa-clean-'));
|
|
const sibling = path.join(dir, 'sibling');
|
|
fs.mkdirSync(sibling);
|
|
fs.writeFileSync(path.join(sibling, 'browse.json'), 'untouched');
|
|
fs.symlinkSync(sibling, path.join(dir, '.gstack'));
|
|
try {
|
|
await expect(stopQaOnlyBrowser(dir, 200)).rejects.toThrow('state directory is a link');
|
|
expect(fs.readFileSync(path.join(sibling, 'browse.json'), 'utf8')).toBe('untouched');
|
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test.each(['ack-only', 'replaced-state', 'terminal-mismatch', 'chromium-mismatch', 'missing-terminal', 'blocked-identity'])('cleanup refuses unconfirmed settlement: %s', async scenario => {
|
|
const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qa-clean-'));
|
|
const stateDir = path.join(dir, '.gstack');
|
|
fs.mkdirSync(stateDir);
|
|
const script = path.join(dir, 'server.ts');
|
|
const stateFile = path.join(stateDir, 'browse.json');
|
|
const requestFile = path.join(dir, 'request.json');
|
|
const signalFile = path.join(dir, 'signal.json');
|
|
fs.writeFileSync(script, `
|
|
import * as fs from 'node:fs';
|
|
const chromium = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)', 'chromium']);
|
|
process.on('SIGTERM', async () => { chromium.kill(); await chromium.exited; process.exit(0); });
|
|
fs.writeFileSync(${JSON.stringify(path.join(dir, 'chromium-pid'))}, String(chromium.pid));
|
|
process.on('SIGINT', () => {
|
|
fs.writeFileSync(${JSON.stringify(signalFile)}, JSON.stringify({ signal: 'SIGINT' }));
|
|
if (${JSON.stringify(scenario)} === 'replaced-state') {
|
|
const file = ${JSON.stringify(stateFile)};
|
|
fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), instanceId: 'successor' }));
|
|
}
|
|
});
|
|
const server = Bun.serve({ port: 0, async fetch(request) {
|
|
fs.writeFileSync(${JSON.stringify(requestFile)}, JSON.stringify(await request.json()));
|
|
return new Response('Unexpected HTTP request');
|
|
} });
|
|
fs.writeFileSync(${JSON.stringify(path.join(dir, 'port'))}, String(server.port));
|
|
`);
|
|
const daemon = Bun.spawn([process.execPath, script], { cwd: dir, stdout: 'ignore', stderr: 'inherit' });
|
|
let agent: ReturnType<typeof readAgentRecord>;
|
|
let restore: (() => void) | undefined;
|
|
let worker: ReturnType<typeof Bun.spawn> | undefined;
|
|
try {
|
|
await waitFor(() => fs.existsSync(path.join(dir, 'port')));
|
|
const port = Number(fs.readFileSync(path.join(dir, 'port'), 'utf8'));
|
|
const chromiumPid = Number(fs.readFileSync(path.join(dir, 'chromium-pid'), 'utf8'));
|
|
expect(spawnTerminalAgent({ stateFile, serverPort: port, ownerPid: daemon.pid, cwd: dir,
|
|
extraEnv: { GSTACK_TERMINAL_OWNER_WATCHDOG_MS: '25' } })).toBeGreaterThan(0);
|
|
agent = readAgentRecord(stateDir);
|
|
expect(agent).not.toBeNull();
|
|
fs.writeFileSync(stateFile, JSON.stringify({ pid: daemon.pid, port, token: 'fixture-local-token', instanceId: 'owned', serverPath: script,
|
|
chromiumPid, chromiumStartTime: scenario === 'chromium-mismatch' ? 'stale start' : readPidStartTime(chromiumPid) }));
|
|
const agentFile = path.join(stateDir, 'terminal-agent-pid');
|
|
if (scenario === 'terminal-mismatch') fs.writeFileSync(agentFile, JSON.stringify({ ...agent, ownerPid: process.pid }));
|
|
if (scenario === 'missing-terminal') fs.unlinkSync(agentFile);
|
|
if (scenario === 'blocked-identity') {
|
|
const preload = path.join(dir, 'block-identity.ts');
|
|
fs.writeFileSync(preload, `
|
|
import * as fs from 'node:fs';
|
|
const original = Bun.spawnSync;
|
|
Bun.spawnSync = (...args) => {
|
|
if (args[0][0] === 'ps') {
|
|
fs.writeFileSync(${JSON.stringify(path.join(dir, 'identity-blocked'))}, String(process.pid));
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
fs.writeFileSync(${JSON.stringify(path.join(dir, 'late-identity'))}, 'unexpected late work');
|
|
}
|
|
return original(...args);
|
|
};
|
|
`);
|
|
const original = Bun.spawn;
|
|
const spy = spyOn(Bun, 'spawn').mockImplementation(((args: string[], options: any) => {
|
|
expect(args[1]).toBe(path.join(ROOT, 'test/helpers/qa-only-cleanup.ts'));
|
|
worker = original([args[0], '--preload', preload, ...args.slice(1)], options);
|
|
return worker;
|
|
}) as typeof Bun.spawn);
|
|
restore = () => spy.mockRestore();
|
|
}
|
|
const started = performance.now();
|
|
const errors = { 'ack-only': 'settlement deadline exceeded', 'replaced-state': 'daemon state was replaced',
|
|
'terminal-mismatch': 'terminal ownership is unconfirmed', 'chromium-mismatch': 'Chromium ownership is unconfirmed',
|
|
'missing-terminal': 'terminal identity is unavailable', 'blocked-identity': 'owned worker settlement deadline exceeded' };
|
|
await expect(stopQaOnlyBrowser(dir, 300)).rejects.toThrow(errors[scenario]);
|
|
expect(performance.now() - started).toBeLessThan(1000);
|
|
expect(daemon.exitCode).toBeNull();
|
|
expect(readPidStartTime(chromiumPid)).not.toBe('');
|
|
expect(fs.existsSync(stateFile)).toBe(true);
|
|
if (scenario === 'blocked-identity') {
|
|
expect(fs.readFileSync(path.join(dir, 'identity-blocked'), 'utf8')).toBe(String(worker!.pid));
|
|
expect(await worker!.exited).toBe(137);
|
|
expect(fs.existsSync(path.join(dir, 'late-identity'))).toBe(false);
|
|
}
|
|
if (['ack-only', 'replaced-state'].includes(scenario)) expect(JSON.parse(fs.readFileSync(signalFile, 'utf8'))).toEqual({ signal: 'SIGINT' });
|
|
else expect(fs.existsSync(signalFile)).toBe(false);
|
|
expect(fs.existsSync(requestFile)).toBe(false);
|
|
} finally {
|
|
restore?.();
|
|
if (agent) expect(stopAgentByRecord(agent, 300)).toBe(true);
|
|
daemon.kill();
|
|
await daemon.exited;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}, 15_000);
|
|
|
|
test.each(['owned-endpoint', 'sibling-endpoint'])('real Chromium and terminal settle before removal without harming a live sibling or recreating state: %s', async scenario => {
|
|
const page = Bun.serve({ port: 0, fetch: () => new Response('<h1>Owned QA fixture</h1>', { headers: { 'Content-Type': 'text/html' } }) });
|
|
const fixtures: Array<{ dir: string; child: ReturnType<typeof Bun.spawn>; state?: any; agent?: ReturnType<typeof readAgentRecord> }> = [];
|
|
const launch = async () => {
|
|
const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qa-live-'));
|
|
const stateFile = path.join(dir, '.gstack/browse.json');
|
|
const child = Bun.spawn([process.execPath, path.join(ROOT, 'browse/src/server.ts')], {
|
|
cwd: dir, stdout: Bun.file(path.join(dir, 'server.log')), stderr: Bun.file(path.join(dir, 'server-error.log')),
|
|
env: { ...process.env, PATH: path.dirname(process.execPath) + path.delimiter + process.env.PATH,
|
|
BROWSE_STATE_FILE: stateFile, BROWSE_PORT: '0', BROWSE_PARENT_PID: '0', BROWSE_HEADED: '0', BROWSE_HEADLESS_SKIP: '0',
|
|
GSTACK_TERMINAL_OWNER_WATCHDOG_MS: '25', GSTACK_AGENT_WATCHDOG_TICK_MS: '100',
|
|
TMPDIR: dir, TMP: dir, TEMP: dir, CHROMIUM_PROFILE: path.join(dir, 'profile') },
|
|
});
|
|
const fixture = { dir, child, state: undefined as any, agent: undefined as ReturnType<typeof readAgentRecord> };
|
|
fixtures.push(fixture);
|
|
await waitFor(() => fs.existsSync(stateFile) && fs.existsSync(path.join(dir, '.gstack/terminal-port')));
|
|
fixture.state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
fixture.agent = readAgentRecord(path.dirname(stateFile));
|
|
expect(fixture.state.pid).toBe(child.pid);
|
|
const children = spawnSync('ps', ['-eo', 'pid=,ppid=,args='], { encoding: 'utf8', timeout: 1000 });
|
|
expect(children.status).toBe(0);
|
|
expect(children.stdout.split('\n').some(line => {
|
|
const row = line.trim().split(/\s+/);
|
|
return Number(row[1]) === child.pid && /chrom|headless_shell/.test(row.slice(2).join(' '));
|
|
})).toBe(true);
|
|
expect(isOurAgent(fixture.agent!, child.pid)).toBe(true);
|
|
const response = await fetch(`http://127.0.0.1:${fixture.state.port}/command`, { method: 'POST',
|
|
headers: { Authorization: `Bearer ${fixture.state.token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: 'goto', args: [`http://127.0.0.1:${page.port}/`] }), signal: AbortSignal.timeout(3000) });
|
|
expect(response.ok, await response.text()).toBe(true);
|
|
return fixture;
|
|
};
|
|
try {
|
|
const owned = await launch(), sibling = await launch();
|
|
const stateFile = path.join(owned.dir, '.gstack/browse.json');
|
|
const original = fs.readFileSync(stateFile, 'utf8');
|
|
fs.writeFileSync(stateFile, JSON.stringify(sibling.state));
|
|
await expect(stopQaOnlyBrowser(owned.dir, 1000)).rejects.toThrow('daemon belongs to another fixture');
|
|
fs.writeFileSync(stateFile, original);
|
|
if (scenario === 'sibling-endpoint') fs.writeFileSync(stateFile, JSON.stringify({
|
|
...owned.state, port: sibling.state.port, token: sibling.state.token,
|
|
}));
|
|
const started = performance.now();
|
|
let cleanupError: unknown;
|
|
try { await stopQaOnlyBrowser(owned.dir, 4000); }
|
|
catch (error) { cleanupError = error; }
|
|
expect(isOurAgent(sibling.agent!, sibling.child.pid), 'Sibling terminal must survive cleanup even when only its endpoint was copied').toBe(true);
|
|
if (cleanupError) throw cleanupError;
|
|
expect(performance.now() - started).toBeLessThan(4000);
|
|
expect([0, 137]).toContain(await owned.child.exited);
|
|
expect(isAgentRecordGone(owned.agent!)).toBe(true);
|
|
fs.rmSync(owned.dir, { recursive: true, force: true });
|
|
const until = performance.now() + 250;
|
|
while (performance.now() < until) {
|
|
expect(fs.existsSync(owned.dir)).toBe(false);
|
|
expect(isOurAgent(sibling.agent!, sibling.child.pid)).toBe(true);
|
|
await Bun.sleep(25);
|
|
}
|
|
expect((await fetch(`http://127.0.0.1:${sibling.state.port}/health`, { signal: AbortSignal.timeout(1000) })).ok).toBe(true);
|
|
await stopQaOnlyBrowser(sibling.dir, 4000);
|
|
expect([0, 137]).toContain(await sibling.child.exited);
|
|
expect(isAgentRecordGone(sibling.agent!)).toBe(true);
|
|
fs.rmSync(sibling.dir, { recursive: true, force: true });
|
|
await Bun.sleep(100);
|
|
expect(fixtures.every(fixture => !fs.existsSync(fixture.dir))).toBe(true);
|
|
} finally {
|
|
page.stop(true);
|
|
for (const fixture of fixtures) {
|
|
if (!fs.existsSync(fixture.dir)) continue;
|
|
if (fixture.state) fs.writeFileSync(path.join(fixture.dir, '.gstack/browse.json'), JSON.stringify(fixture.state));
|
|
try { await stopQaOnlyBrowser(fixture.dir, 4000); }
|
|
catch { fixture.child.kill('SIGINT'); }
|
|
await Promise.race([fixture.child.exited, Bun.sleep(3000)]);
|
|
if (fixture.child.exitCode === null) fixture.child.kill('SIGKILL');
|
|
await fixture.child.exited;
|
|
if (fixture.agent) expect(stopAgentByRecord(fixture.agent, 300)).toBe(true);
|
|
fs.rmSync(fixture.dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}, 30_000);
|