Files
gstack/test/pty-screen-supervision.test.ts
T
Garry Tan dcaea52800 v1.91.7.0 feat: add functional QA and pre-publication docs checks (#2983)
* 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
2026-09-29 06:07:35 -07:00

208 lines
12 KiB
TypeScript

import { expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const terminal = `class {
unicode = { register() {}, activeVersion: '' };
buffer = { active: { baseY: 0, getLine: () => ({ getCell: () => undefined, translateToString: () => 'partial viewport' }) } };
write(text, done) {
controls.writes++;
if (text.includes('reject')) throw controls.original;
if (text.includes('stall')) { controls.callback = done; return; }
done(); done();
}
dispose() { controls.disposals++; if (controls.disposeThrows) throw new Error('secondary disposal failure'); }
}`;
function adapter() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-drain-'));
const screen = path.join(dir, 'screen.ts');
const runner = path.join(dir, 'runner.ts');
fs.writeFileSync(screen, fs.readFileSync(path.join(ROOT, 'test/helpers/pty-screen.ts'), 'utf8')
.replace('const Terminal = await loadTerminal();', `const Terminal = ${terminal};`) +
`\nexport const controls = { writes: 0, disposals: 0, callback: undefined, disposeThrows: false, original: new Error('original parser rejection') };\n`);
fs.writeFileSync(runner, fs.readFileSync(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'), 'utf8')
.replaceAll('fixture.cleanup();', 'globalThis.beforeFixtureCleanup?.(fixture); fixture.cleanup();')
.replace(/from (['"])(\.\.?\/[^'"]+)\1/g, (_match, _quote, relative) =>
'from ' + JSON.stringify(pathToFileURL(relative === './pty-screen' ? screen :
path.resolve(ROOT, 'test/helpers', relative + '.ts')).href)));
return { dir, screen: pathToFileURL(screen).href, runner: pathToFileURL(runner).href };
}
test('actual write callbacks settle once; stalled reads and disposal reject at the original absolute deadline', async () => {
const a = adapter();
try {
const { createPtyScreen, controls } = await import(a.screen);
const deadlineAt = performance.now() + 80;
const screen = await createPtyScreen(10, 2, { deadlineAt });
screen.write('complete');
expect((await screen.readFrame()).inputOffset).toBe(8);
screen.write('stall');
const reads = [screen.read(), screen.readFrame()];
const closing = screen.dispose();
expect(screen.dispose()).toBe(closing);
const settled = await Promise.allSettled([...reads, closing]);
expect(settled.every(result => result.status === 'rejected')).toBe(true);
expect(new Set(settled.map(result => (result as PromiseRejectedResult).reason)).size).toBe(1);
expect(performance.now()).toBeLessThan(deadlineAt + 500);
expect(controls.disposals).toBe(1);
controls.callback(); controls.callback();
await expect(screen.readFrame()).rejects.toThrow('viewport is incomplete');
await expect(screen.dispose()).rejects.toThrow('viewport is incomplete');
} finally { fs.rmSync(a.dir, { recursive: true, force: true }); }
});
test('cancellation and parser rejection preserve the first failure through repeated disposal', async () => {
const a = adapter();
try {
const { createPtyScreen, controls } = await import(a.screen);
const abort = new AbortController();
const screen = await createPtyScreen(10, 2, { deadlineAt: performance.now() + 600_000, signal: abort.signal });
screen.write('stall');
const read = screen.readFrame();
abort.abort(controls.original);
const error = await read.catch((error: Error) => error);
expect(error.cause).toBe(controls.original);
await expect(screen.dispose()).rejects.toBe(error);
const rejected = await createPtyScreen(10, 2);
rejected.write('stall'); rejected.write('reject');
controls.disposeThrows = true;
const failure = await rejected.readFrame().catch((error: Error) => error);
expect(failure.cause).toBe(controls.original);
await expect(rejected.dispose()).rejects.toBe(failure);
await expect(rejected.dispose()).rejects.toBe(failure);
expect(controls.disposals).toBe(2);
} finally { fs.rmSync(a.dir, { recursive: true, force: true }); }
});
test('actual PTY close bounds live, already-exited, wall, spawn-failure and unresponsive-child paths', () => {
const a = adapter();
const worker = path.join(a.dir, 'worker.ts');
try {
fs.writeFileSync(worker, `import {launchClaudePty} from ${JSON.stringify(a.runner)};
import {controls} from ${JSON.stringify(a.screen)};
const realSpawn = Bun.spawn;
for (const mode of ['live','exited','wall','unresponsive','spawn-failure']) {
let resolveExit; const signals=[];
Bun.spawn = (_args, opts) => {
opts.terminal.data(null,Buffer.from('stall retained raw prefix'));
if(mode==='spawn-failure') throw controls.original;
return {pid:123,exited:mode==='exited'?Promise.resolve(0):new Promise(resolve=>resolveExit=resolve),
kill:signal=>{signals.push(signal);if(mode!=='unresponsive')resolveExit(0);},terminal:{write(){}}};
};
const before=performance.now(), disposed=controls.disposals;
if(mode==='spawn-failure') {
const error=await launchClaudePty({observeScreen:true,timeoutMs:600000}).catch(error=>error);
if(error!==controls.original)throw Error('spawn error replaced');
} else {
const session=await launchClaudePty({observeScreen:true,timeoutMs:mode==='wall'?60:600000});
if(mode==='exited')await Bun.sleep(1);
if(mode==='wall')await Bun.sleep(80);
const first=session.close();if(session.close()!==first)throw Error('close promise changed');
const read=session.currentScreenFrame();
const results=await Promise.allSettled([first,read,session.close()]);
if(results.some(result=>result.status!=='rejected'))throw Error('incomplete viewport passed');
if(!session.rawOutput().includes('retained raw prefix'))throw Error('raw diagnostics lost');
if(mode==='unresponsive'&&signals.join()!=='SIGINT,SIGKILL')throw Error('process cleanup skipped');
if(mode==='exited'&&signals.length)throw Error('already-exited child signalled');
}
if(controls.disposals!==disposed+1)throw Error('screen disposed more than once');
if(performance.now()-before>3500)throw Error('cleanup allowance exceeded');
console.log(mode);
}
Bun.spawn=realSpawn;
`);
const result = spawnSync(process.execPath, [worker], { cwd: ROOT, encoding: 'utf8', timeout: 15_000,
env: { ...process.env, BROWSE_TERMINAL_BINARY: process.execPath, EVALS_HERMETIC: '0' } });
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim().split('\n')).toEqual(['live','exited','wall','unresponsive','spawn-failure']);
} finally { fs.rmSync(a.dir, { recursive: true, force: true }); }
}, 20_000);
test.each(['counting', 'floor'])('%s attempts retain public evidence before actual cleanup within unchanged clocks', (mode) => {
const a = adapter();
const worker = path.join(a.dir, 'budget-worker.ts');
try {
const entry = fs.readFileSync(path.join(ROOT, 'test/skill-e2e-plan-design-with-ui.test.ts'), 'utf8');
expect(entry).toContain('timeoutMs: 600_000 - (Date.now() - startedAt)');
fs.writeFileSync(worker, `import * as fs from 'node:fs';
import * as path from 'node:path';
import {runPlanSkillCounting,runPlanSkillFloorCheck} from ${JSON.stringify(a.runner)};
const mode=${JSON.stringify(mode)};
let clock=0, sequence=0;
const epoch=Date.now(), realSleep=Bun.sleep.bind(Bun), timers=new Map();
Object.defineProperty(performance,'now',{value:()=>clock});
Date.now=()=>epoch+clock;
globalThis.setTimeout=(callback,ms=0)=>{const id=++sequence;timers.set(id,{at:clock+Math.max(0,ms),callback});return id;};
globalThis.clearTimeout=id=>timers.delete(id);
globalThis.setInterval=()=>++sequence;globalThis.clearInterval=()=>{};
Bun.sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
const attempts=[], base=${JSON.stringify(a.dir)};
for(let attempt=1;attempt<=2;attempt++) {
const startedAt=Date.now(), timerCount=timers.size;
clock+=1700;
const config=path.join(base,'config-'+attempt), artifacts=path.join(base,'artifacts-'+attempt);
fs.mkdirSync(path.join(config,'projects','public'),{recursive:true});
process.env.GSTACK_EVAL_DIR=artifacts;
let fixtureCwd, captured, cleaned=false, exited=false, signals=[];
Bun.spawn=(_args,opts)=>{
fixtureCwd=opts.cwd;
let resolveExit;
return {pid:100+attempt,exited:new Promise(resolve=>resolveExit=resolve),
terminal:{write(input){if(!input.includes('/plan-design-review'))return;
const question={header:'Layout',question:'Which layout should the UI use?',options:[{label:'One panel'},{label:'Two panels'}]};
const event={cwd:opts.cwd,sessionId:'public',timestamp:new Date(Date.now()).toISOString(),isSidechain:false,
message:{role:'assistant',content:[{type:'tool_use',id:'question-'+attempt,name:'AskUserQuestion',input:{questions:[question]}}]}};
fs.appendFileSync(path.join(config,'projects/public/public.jsonl'),JSON.stringify(event)+'\\n');
opts.terminal.data(null,Buffer.from('stall public question prefix attempt '+attempt));
}},kill(signal){signals.push(signal);exited=true;resolveExit(0);}};
};
const captures=()=>fs.existsSync(artifacts)?fs.readdirSync(artifacts,{recursive:true}).filter(file=>String(file).endsWith('observation.json')).map(file=>path.join(artifacts,String(file))):[];
globalThis.beforeFixtureCleanup=fixture=>{
if(!fs.existsSync(fixture.cwd))throw Error('cleanup preceded capture');
const files=captures();if(files.length!==1)throw Error('attempt capture missing');
captured=JSON.parse(fs.readFileSync(files[0],'utf8'));
if(captured.state!=='threw'||!captured.error.includes('incomplete'))throw Error('failure classification lost');
if(!captured.publicTools.some(event=>event.toolUseId==='question-'+attempt))throw Error('public question lost');
if(!fs.readFileSync(path.join(path.dirname(files[0]),'terminal.raw.log'),'utf8').includes('attempt '+attempt))throw Error('raw prefix lost');
cleaned=true;
};
let done=false, error;
const run=mode==='counting'?runPlanSkillCounting:runPlanSkillFloorCheck;
const helper=run({skillName:'plan-design-review',slashCommand:'/plan-design-review',
followUpPrompt:'# UI input',fixtureFiles:{'review-input.md':'# UI plan'},isLastStep0AUQ:()=>false,reviewCountCeiling:1,
timeoutMs:mode==='counting'?600_000-(Date.now()-startedAt):600_000,env:{CLAUDE_CONFIG_DIR:config}})
.catch(value=>error=value).finally(()=>done=true);
for(let steps=0;!done&&steps<2000;steps++) {
await realSleep(0);
if(done)break;
const next=[...timers].sort((a,b)=>a[1].at-b[1].at)[0];
if(!next)throw Error('helper stalled without a settlement timer');
timers.delete(next[0]);clock=Math.max(clock,next[1].at);next[1].callback();
}
await helper;
if(!error?.message.includes('viewport is incomplete')||!cleaned||!exited||fs.existsSync(fixtureCwd))throw Error('actual cleanup or original failure lost');
if(Date.now()-startedAt>(mode==='counting'?600000:660000)||timers.size!==timerCount)throw Error('budget reset or timer leak');
if(captured.capture.cwd!==fixtureCwd||!captures().length)throw Error('durable attempt identity lost');
attempts.push({attempt,elapsed:Date.now()-startedAt,signals,fixtureCwd,artifacts});
}
if(clock>1800000||attempts[0].fixtureCwd===attempts[1].fixtureCwd)throw Error('file wall or attempt isolation failed');
console.log(JSON.stringify({attempts,total:clock,fileWall:1800000}));
`);
const result = spawnSync(process.execPath, [worker], { cwd: ROOT, encoding: 'utf8', timeout: 20_000,
env: { ...process.env, BROWSE_TERMINAL_BINARY: process.execPath, EVALS_HERMETIC: '1', EVALS_RUN_ID: '',
GSTACK_EVAL_DIR: '', TMPDIR: a.dir } });
expect(result.status, result.stdout + result.stderr).toBe(0);
const proof = JSON.parse(result.stdout.trim().split('\n').at(-1)!);
expect(proof.attempts).toHaveLength(2);
expect(proof.attempts.map((attempt: { elapsed: number }) => attempt.elapsed))
.toEqual(mode === 'counting' ? [595000, 595000] : [609700, 609700]);
expect(proof.total).toBe(mode === 'counting' ? 1190000 : 1219400);
expect(proof.total).toBeLessThan(proof.fileWall);
} finally { fs.rmSync(a.dir, { recursive: true, force: true }); }
}, 25_000);