mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-22 04:40:44 +02:00
* v1.87.5.0 perf: remove idle waits from tests and CI planning * fix: settle split PTY redraws before routing input * docs: record final burst-safe test benchmarks * fix: keep cold-setup snapshot metadata dependency-free * fix: avoid early-reader pipe races in artifact URL parsing * fix: preserve safety matches for multiline command payloads * fix: recognize concurrent CSO publication removal * test: preload the UI design-review target before invocation * docs: record validation blocker fixes * fix: bind plan observer rejection to the invoked command * fix: count only native design decisions in the UI gate * docs: clarify UI-positive eval evidence requirements * test: recognize native UI decisions without weakening finding counts * test: decouple native UI evidence from question punctuation * test: recognize concrete native UI decisions independently of prose format * fix: retain failed eval logs under the hidden CI cache * test: await telemetry completion instead of racing disk writes
147 lines
9.1 KiB
TypeScript
147 lines
9.1 KiB
TypeScript
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 { pathToFileURL } from 'node:url';
|
||
import { capturePlanCountQuestion, planCountQuestionInput, planCountSubmissionInput } from './helpers/claude-pty-runner';
|
||
|
||
const captured = fs.readFileSync(path.join(import.meta.dir, 'fixtures/ceo-checkbox-l.screen.txt'), 'utf8');
|
||
const question = captured.slice(captured.lastIndexOf('←'));
|
||
const selected = question.replace('❯ 1. [ ]', '❯ 1. [✓]');
|
||
|
||
function focused(index: number) {
|
||
const base = selected.replace('❯ 1.', ' 1.');
|
||
if (index === 5) return base.replace(/^ +Submit$/m, '❯ Submit');
|
||
return base.replace(new RegExp(`^ +${index + 1}\\.`, 'm'), `❯ ${index + 1}.`);
|
||
}
|
||
|
||
describe('Native checkbox question controls', () => {
|
||
test('the captured unchecked menu toggles one choice and does not immediately toggle it off', () => {
|
||
expect(planCountSubmissionInput(captured)).toBeNull();
|
||
const fp = capturePlanCountQuestion(captured, new Set(), 0, false)!;
|
||
expect(planCountQuestionInput(captured, fp, 1)).toBe('1');
|
||
expect(planCountSubmissionInput(selected)).toBe('\t');
|
||
});
|
||
|
||
test('selected checkbox rows navigate one focus step at a time; only the actual button submits', () => {
|
||
for (let index = 0; index < 5; index++) expect(planCountSubmissionInput(focused(index))).toBe('\t');
|
||
expect(planCountSubmissionInput(focused(5))).toBe('\r');
|
||
expect(planCountSubmissionInput(focused(5).replace('Submit\n', 'Next\n'))).toBe('\r');
|
||
expect(planCountSubmissionInput('```text\nold code\n```\n' + selected)).toBe('\t');
|
||
});
|
||
|
||
test('quoted, incomplete, superseded and unselected panels cannot advance', () => {
|
||
for (const text of [
|
||
question,
|
||
selected.replace(/^ +Submit$/m, ''),
|
||
selected.replace(/Enter to select.*$/m, ''),
|
||
selected.replace('←', '> ←'),
|
||
selected.split('\n').map(line => ' ' + line).join('\n'),
|
||
'```text\n' + selected,
|
||
'Example panel:\n' + selected,
|
||
'Quoted source:\n' + selected,
|
||
selected + '\n☐ New question\n❯ 1. Yes\n2. No\nEnter to select · ↑/↓ to navigate · Esc to cancel',
|
||
selected.replace(' 2. [ ]', ' 8. [ ]'),
|
||
selected.replace('❯ 1.', ' 1.').replace('6. Chat', '❯ 6. Chat'),
|
||
]) expect(planCountSubmissionInput(text)).toBeNull();
|
||
});
|
||
|
||
test.skipIf(process.platform === 'win32')('real PTY submits one selected checkbox with early or deferred native metadata', async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-checkbox-'));
|
||
const fake = path.join(dir, 'fake-claude');
|
||
const worker = path.join(dir, 'worker.ts');
|
||
const output = path.join(dir, 'results.json');
|
||
const cases = [false, true].map(late => ({ late, log: path.join(dir, `${late}.jsonl`), report: path.join(dir, `${late}.md`) }));
|
||
fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw`
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
const item = JSON.parse(process.env.CHECKBOX_CASE);
|
||
const log = event => fs.appendFileSync(item.log, JSON.stringify(event) + '\n');
|
||
log({type:'startup',pid:process.pid});
|
||
const sessionId = 'checkbox-' + process.pid;
|
||
const project = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', sessionId);
|
||
fs.mkdirSync(project, {recursive:true});
|
||
const native = (role, content, extra={}) => fs.appendFileSync(path.join(project, sessionId+'.jsonl'), JSON.stringify({cwd:process.cwd(),sessionId,isSidechain:false,timestamp:new Date().toISOString(),message:{role,content},...extra})+'\n');
|
||
native('assistant',[{type:'text',text:'Fixture started.'}]);
|
||
const q = {header:'Test specification',question:'Which verification should the plan specify? <gstack-qid:checkbox-verification>',multiSelect:true,options:[{label:'Happy path'},{label:'Email failure'},{label:'Nil input'},{label:'SQL injection'}]};
|
||
const ask = () => native('assistant',[{type:'tool_use',name:'AskUserQuestion',id:'checkbox',input:{questions:[q]}}]);
|
||
let started=false, focus=0, checked=false, stage='question', done=false;
|
||
function render() {
|
||
let screen;
|
||
if(stage==='question') screen='← '+(checked?'☒':'☐')+' Test specification ✔ Submit →\n│ '+q.question+'\n'+[...q.options.map(o=>o.label),'Type something'].map((label,i)=>(focus===i?'❯':' ')+' '+(i+1)+'. ['+(i===0&&checked?'✓':' ')+'] '+label).join('\n')+'\n'+(focus===5?'❯':' ')+' Submit\n──────────────────\n 6. Chat about this\nEnter to select · ↑/↓ to navigate · Esc to cancel\n';
|
||
else screen='← ☒ Test specification ✔ Submit →\nReview your answers\nHappy path\nReady to submit your answers?\n❯1.Submit answers\n2.Cancel\n';
|
||
process.stdout.write('\x1b[2J\x1b[H'+screen.replace(/\n/g,'\r\n'));
|
||
log({type:'screen',stage,focus,checked});
|
||
}
|
||
process.stdin.setRawMode?.(true);
|
||
process.stdin.on('data', async data => {
|
||
const input=data.toString(); log({type:'input',input});
|
||
if(!started){started=true;if(!item.late)ask();render();return;}
|
||
if(done){log({type:'unexpected',input});return;}
|
||
if(stage==='confirm'){
|
||
if(input!=='\r'||!checked)throw Error('Unanswered confirmation');
|
||
if(item.late)ask();
|
||
native('user',[{type:'tool_result',tool_use_id:'checkbox',content:'Answered.'}],{toolUseResult:{answers:{[q.question]:q.options[0].label}}});
|
||
log({type:'completed',checked});done=true;
|
||
await Bun.sleep(10);
|
||
fs.writeFileSync(item.report,'# Reviewed plan\n\n## GSTACK REVIEW REPORT\n\n| Review | Status | Findings |\n|---|---|---|\n| Eng | complete | resolved |\n\nVERDICT: READY\n\nNO UNRESOLVED DECISIONS\n');
|
||
native('assistant',[{type:'text',text:'Eng review complete.'}]);
|
||
process.stdout.write('\x1b[2J\x1b[H## Completion Summary\r\nEng review complete.\r\n');return;
|
||
}
|
||
for(const key of input){
|
||
if(key==='1')checked=!checked;
|
||
else if(key==='\t')focus=Math.min(5,focus+1);
|
||
else if(key==='\r'){if(focus===5)stage='confirm';else if(focus===0)checked=!checked;}
|
||
else throw Error('Unexpected checkbox input '+JSON.stringify(input));
|
||
}
|
||
render();
|
||
});
|
||
process.on('SIGINT',()=>process.exit(0));
|
||
process.stdin.resume();
|
||
process.stdout.write('PTY_READY:'+item.log+'\x1b[2J\x1b[H');
|
||
`);
|
||
fs.chmodSync(fake, 0o755);
|
||
fs.writeFileSync(worker, `import {runPlanSkillCounting} from ${JSON.stringify(pathToFileURL(path.resolve(import.meta.dir, 'helpers/claude-pty-runner.ts')).href)};
|
||
const cases=${JSON.stringify(cases)};
|
||
const results=await Promise.all(cases.map(async item=>({late:item.late,observation:await runPlanSkillCounting({skillName:'plan-eng-review',slashCommand:'/plan-eng-review',followUpPrompt:'Review this fixture.',isLastStep0AUQ:()=>false,isReviewAUQ:()=>true,reviewCountCeiling:8,timeoutMs:48000,startupReadyMarker:'PTY_READY:'+item.log,expectedPlanPath:item.report,env:{CHECKBOX_CASE:JSON.stringify(item)}})})));
|
||
await Bun.write(${JSON.stringify(output)},JSON.stringify(results));`);
|
||
const child = Bun.spawn([process.execPath, worker], {
|
||
env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' }, stdout: 'pipe', stderr: 'pipe',
|
||
});
|
||
const watchdog = setTimeout(() => child.kill('SIGKILL'), 48000);
|
||
try {
|
||
const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||
expect(code, stdout + stderr).toBe(0);
|
||
const results = JSON.parse(fs.readFileSync(output, 'utf8'));
|
||
for (const item of cases) {
|
||
const observation = results.find((row: any) => row.late === item.late).observation;
|
||
expect(observation.outcome, JSON.stringify(observation)).toBe('completion_summary');
|
||
expect(observation.reviewCount).toBe(1);
|
||
expect(observation.transcript.calls).toHaveLength(1);
|
||
expect(observation.transcript.calls[0].unansweredQuestionIndices).toEqual([]);
|
||
expect(Object.values(observation.transcript.calls[0].answers)).toEqual(['Happy path']);
|
||
const events = fs.readFileSync(item.log, 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
||
expect(events.filter(event => event.type === 'completed')).toHaveLength(1);
|
||
expect(events.filter(event => event.type === 'unexpected')).toHaveLength(0);
|
||
expect(events.filter(event => event.type === 'input').slice(1).map(event => event.input))
|
||
.toEqual(['1', '\t', '\t', '\t', '\t', '\t', '\r', '\r']);
|
||
expect(fs.readFileSync(item.report, 'utf8')).toEndWith('NO UNRESOLVED DECISIONS\n');
|
||
}
|
||
} finally {
|
||
clearTimeout(watchdog);
|
||
child.kill('SIGKILL');
|
||
await child.exited;
|
||
for (const item of cases) {
|
||
if (!fs.existsSync(item.log)) continue;
|
||
const startup = fs.readFileSync(item.log, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line)).find(event => event.type === 'startup');
|
||
if (!startup || !Number.isInteger(startup.pid) || startup.pid <= 0) continue;
|
||
try {
|
||
if (process.platform === 'linux' && !fs.readFileSync(`/proc/${startup.pid}/cmdline`, 'utf8').includes(fake)) continue;
|
||
process.kill(startup.pid, 'SIGKILL');
|
||
} catch { /* the owned fake child already exited */ }
|
||
}
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}, 55000);
|
||
});
|