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
208 lines
12 KiB
TypeScript
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);
|