Files
gstack/test/pty-screen-session.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

152 lines
9.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const ROOT = path.resolve(import.meta.dir, '..');
const runner = pathToFileURL(path.join(ROOT, 'test/helpers/claude-pty-runner.ts')).href;
function fixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-screen-session-'));
const fake = path.join(dir, 'fake-claude');
const dispatched = path.join(dir, 'dispatched');
fs.writeFileSync(fake, `#!${process.execPath}\n` + `
import * as fs from 'node:fs';
fs.writeFileSync(${JSON.stringify(dispatched)}, 'started');
process.stdin.setRawMode?.(true);
process.stdout.write('\\x1b[?1049h\\x1b[2J\\x1b[HDo you want to create plan.md?\\r\\n❯1.Yes\\r\\n2.No\\r\\nEsc to cancel · Tab to amend');
process.stdin.on('data', data => {
const input = data.toString();
if(input.includes('exit')) process.exit(0);
if(input.includes('1')) process.stdout.write('\\x1b[2J\\x1b[H⎿ Wrote 44 lines\\r\\n❯ ');
});
setInterval(() => {}, 1000);
`, { mode: 0o755 });
return { dir, fake, dispatched };
}
function run(dir: string, fake: string, source: string) {
const worker = path.join(dir, 'worker.ts');
fs.writeFileSync(worker, source);
return spawnSync(process.execPath, [worker], { cwd: ROOT, encoding: 'utf8', timeout: 12_000,
env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' } });
}
describe.skipIf(process.platform === 'win32')('real fake-PTY viewport lifecycle', () => {
test('a completed Write clears actionable permission but preserves raw history and final screen after exit', () => {
const f = fixture();
try {
const result = run(f.dir, f.fake, `
import { launchClaudePty, capturePlanCountQuestion, isPermissionDialogVisible } from ${JSON.stringify(runner)};
const session = await launchClaudePty({ cwd:${JSON.stringify(f.dir)}, observeScreen:true, cols:60, rows:10, timeoutMs:5000 });
const until = async (predicate) => { for(let i=0;i<100;i++){const screen=await session.currentScreen();if(predicate(screen))return screen;await Bun.sleep(20);}throw new Error('fixture screen timeout'); };
try {
const active=await until(s=>s.includes('Do you want'));
if(!isPermissionDialogVisible(active))throw new Error('active permission lost');
session.send('1\\r');
const completed=await until(s=>s.includes('Wrote 44 lines'));
if(completed.includes('Do you want') || capturePlanCountQuestion(completed,new Set(),0,false))throw new Error('stale permission remained actionable');
if(!session.rawOutput().includes('Do you want') || !session.visibleText().includes('Do you want'))throw new Error('raw history lost');
if(completed.split('\\n').length!==10)throw new Error('wrong terminal dimensions');
session.send('exit\\r');
for(let i=0;i<100 && !session.exited();i++)await Bun.sleep(20);
if(!session.exited())throw new Error('fake did not exit');
if(await session.currentScreen()!==completed)throw new Error('final screen changed after disposal');
process.stdout.write('screen-cleared;raw-retained;natural-exit-disposed');
} finally { await session.close(); await session.close(); }
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe('screen-cleared;raw-retained;natural-exit-disposed');
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
}, 15_000);
test('wall timeout disposes its screen and non-opted-in callers retain their existing raw API', () => {
const f = fixture();
try {
const result = run(f.dir, f.fake, `
import { launchClaudePty } from ${JSON.stringify(runner)};
const timed=await launchClaudePty({cwd:${JSON.stringify(f.dir)},observeScreen:true,cols:60,rows:10,timeoutMs:150});
try {
for(let i=0;i<100 && !timed.exited();i++)await Bun.sleep(20);
if(!timed.exited())throw new Error('wall timeout did not stop child');
if((await timed.currentScreen()).split('\\n').length!==10)throw new Error('final timeout viewport unavailable');
} finally {await timed.close();}
const legacy=await launchClaudePty({cwd:${JSON.stringify(f.dir)},timeoutMs:2000});
try {
let rejected=false;try {await legacy.currentScreen();}catch(error){rejected=String(error).includes('not enabled');}
if(!rejected)throw new Error('legacy opted in unexpectedly');
await legacy.waitFor('Do you want',{timeoutMs:1500,pollMs:20});
if(!legacy.visibleText().includes('Do you want'))throw new Error('legacy raw API changed');
process.stdout.write('timeout-disposed;legacy-unchanged');
}finally{await legacy.close();}
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe('timeout-disposed;legacy-unchanged');
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
}, 15_000);
test('a full native summary still completes after its heading scrolls beyond forty rows', async () => {
const f = fixture();
const output = path.join(f.dir, 'plan.md');
const report = '# Reviewed plan\n\n## GSTACK REVIEW REPORT\n\n| Review | Status | Findings |\n|---|---|---|\n| CEO Review | clean | resolved |\n\nVERDICT: CEO CLEARED\n\nNO UNRESOLVED DECISIONS\n';
fs.writeFileSync(f.fake, `#!${process.execPath}\n` + `
import * as fs from 'node:fs';
import * as path from 'node:path';
const id='long-native-summary';
const dir=path.join(process.env.CLAUDE_CONFIG_DIR,'projects',id);fs.mkdirSync(dir,{recursive:true});
const native=(role,content,extra={})=>fs.appendFileSync(path.join(dir,id+'.jsonl'),JSON.stringify({cwd:process.cwd(),sessionId:id,isSidechain:false,timestamp:new Date().toISOString(),message:{role,content},...extra})+'\\n');
let sent=false;process.stdin.setRawMode?.(true);
process.stdin.on('data',()=>{if(sent)return;sent=true;
const q={header:'Architecture',question:'Fix the independent finding?',options:[{label:'Fix it'},{label:'Defer'}]};
native('assistant',[{type:'tool_use',id:'finding',name:'AskUserQuestion',input:{questions:[q]}}]);
native('user',[{type:'tool_result',tool_use_id:'finding',content:'Answered.'}],{timestamp:new Date(Date.now()-100).toISOString(),toolUseResult:{answers:{[q.question]:'Fix it'}}});
fs.writeFileSync(${JSON.stringify(output)},${JSON.stringify(report)});
const text='## Completion Summary\\n'+Array.from({length:48},(_,i)=>'- Finding '+(i+1)+': examined and resolved.').join('\\n')+'\\nNo unresolved issues.';
native('assistant',[{type:'text',text}],{timestamp:new Date(Date.now()+5).toISOString()});
process.stdout.write('\\x1b[2J\\x1b[H'+text.replace(/\\n/g,'\\r\\n')+'\\r\\n❯ ');
});process.stdin.resume();
`, {mode:0o755});
const worker = path.join(f.dir, 'long-summary-worker.ts');
fs.writeFileSync(worker, `import {runPlanSkillCounting} from ${JSON.stringify(runner)};
const result=await runPlanSkillCounting({skillName:'plan-ceo-review',slashCommand:'/plan-ceo-review',followUpPrompt:'# Long summary fixture',expectedPlanPath:${JSON.stringify(output)},isLastStep0AUQ:()=>false,isReviewAUQ:()=>true,reviewCountCeiling:8,timeoutMs:28000});
if(result.outcome!=='completion_summary'||result.reviewCount!==1||result.evidence.includes('## Completion Summary'))throw new Error(JSON.stringify(result));
process.stdout.write('native-summary-complete-after-heading-scrolled');`);
const child = Bun.spawn([process.execPath, worker], {cwd:ROOT,env:{...process.env,EVALS_HERMETIC:'1',EVALS_RUN_ID:'',BROWSE_TERMINAL_BINARY:f.fake},stdout:'pipe',stderr:'pipe'});
const timer=setTimeout(()=>child.kill('SIGKILL'),30000);
try {
const [code,stdout,stderr]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);
expect(code,stderr).toBe(0);
expect(stdout).toBe('native-summary-complete-after-heading-scrolled');
} finally {clearTimeout(timer);child.kill('SIGKILL');fs.rmSync(f.dir,{recursive:true,force:true});}
}, 32000);
test('missing installed headless source fails explicitly before the CLI can spawn', () => {
const f = fixture();
try {
// The viewport module resolves xterm beside itself; a copy placed in the
// fixture resolves the fixture's source-less xterm and replaces the real
// module for the launcher (mock.module), so the real launch path runs.
const screenModule = path.join(ROOT, 'test/helpers/pty/screen.ts');
const copied = path.join(f.dir, 'missing-headless.ts');
fs.writeFileSync(copied, fs.readFileSync(screenModule, 'utf8')
.replace(/from (['"])(\.\.?\/[^'"]+)\1/g, (_match, _quote, relative) =>
'from ' + JSON.stringify(pathToFileURL(path.resolve(path.dirname(screenModule), relative + '.ts')).href)));
const packageDir = path.join(f.dir, 'node_modules/xterm');
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"xterm","version":"fixture"}');
const result = run(f.dir, f.fake, `
import {mock} from 'bun:test';
const missing=await import(${JSON.stringify(pathToFileURL(copied).href)});
mock.module(${JSON.stringify(screenModule)},()=>missing);
const {launchClaudePty}=await import(${JSON.stringify(runner)});
try {const unexpected=await launchClaudePty({cwd:${JSON.stringify(f.dir)},observeScreen:true});await unexpected.close();throw new Error('launch unexpectedly succeeded');}
catch(error){if(!String(error).includes('PTY screen unavailable') || !String(error.cause).includes('headless source is missing'))throw error;process.stdout.write('failed-before-CLI-spawn');}
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe('failed-before-CLI-spawn');
expect(fs.existsSync(f.dispatched)).toBe(false);
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
}, 15_000);
});