Files
gstack/test/pty-screen-supervision.test.ts
garrytan 5df542dc3f refactor(pty): split claude-pty-runner.ts into test/helpers/pty/* behind a barrel
Pure move: every line of the former 5,047-line runner lands verbatim in one
module (four private helpers gain `export` for cross-module use):
binary, screen (absorbs test/helpers/pty-screen.ts, which now re-exports it),
launch, session (PtyDriver), judge, classify, auq, plan-native, boundaries,
runners/{observation,counting,floor}. claude-pty-runner.ts re-exports the
original public surface by name; pty/ modules import siblings directly.

Tests that read the runner's source text:
- rewritten as behavioral: the unit test's model-pin tripwire (fake CLI argv:
  fallback chain, --model before extraArgs, hermetic --strict-mcp-config),
  pty-skill-seeding-wiring (runners through the fake driver; launcher through
  a fake CLI reporting CLAUDE_CONFIG_DIR). The "three wrappers forward model"
  grep is replaced by the runners' fake-driver launch assertions.
- pty-screen-session / pty-screen-supervision: stop copying runner source;
  they mock.module the real pty/screen.ts (and the fixture cleanup) instead.
- re-pointed to the owning module (they execute a sliced runner body with
  injected boundaries; no seam exists for those boundaries yet):
  eng-seeded-completion-ai, plan-floor-permission, plan-create-prepublication,
  plan-count-completion; hermetic-wiring's source guard now reads pty/launch.ts
  and scans every pty/ module for raw process.env spreads.
- plan-count-timeout and pty-output-wake mock the viewport at pty/screen.ts.
2026-09-30 06:05:49 +00:00

221 lines
13 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 screenModule = path.join(ROOT, 'test/helpers/pty/screen.ts');
const fixtureModule = path.join(ROOT, 'test/helpers/plan-count-fixture.ts');
const screen = path.join(dir, 'screen.ts');
const runner = path.join(dir, 'runner.ts');
fs.writeFileSync(screen, fs.readFileSync(screenModule, 'utf8')
.replace('const Terminal = await loadTerminal();', `const Terminal = ${terminal};`)
.replace(/from (['"])(\.\.?\/[^'"]+)\1/g, (_match, _quote, relative) =>
'from ' + JSON.stringify(pathToFileURL(path.resolve(path.dirname(screenModule), relative + '.ts')).href)) +
`\nexport const controls = { writes: 0, disposals: 0, callback: undefined, disposeThrows: false, original: new Error('original parser rejection') };\n`);
// The real harness, with the stub viewport in place of pty/screen.ts and a
// hook that observes each fixture just before the runner cleans it up.
fs.writeFileSync(runner, `import {mock} from 'bun:test';
const screen = await import(${JSON.stringify(pathToFileURL(screen).href)});
mock.module(${JSON.stringify(screenModule)}, () => screen);
const fixtures = { ...await import(${JSON.stringify(pathToFileURL(fixtureModule).href)}) };
mock.module(${JSON.stringify(fixtureModule)}, () => ({ ...fixtures, createPlanCountFixture: (...args) => {
const fixture = fixtures.createPlanCountFixture(...args), cleanup = fixture.cleanup;
fixture.cleanup = () => { globalThis.beforeFixtureCleanup?.(fixture); cleanup(); };
return fixture;
} }));
export const { launchClaudePty, runPlanSkillCounting, runPlanSkillFloorCheck } =
await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.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);