mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 18:05:31 +02:00
* fix: report health failures and coverage accurately * chore: prepare health reporting release 1.87.4.0 * test: restrict routing evaluations to installed project skills * test: stabilize health selection and terminal fixtures
182 lines
16 KiB
TypeScript
182 lines
16 KiB
TypeScript
import {describe,test,expect} 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 {createFilePermissionRecorder,recordFilePermission,currentFilePermissionEpoch} from './helpers/plan-count-file-permission';
|
||
import {createPlanCountPermissionGuard,classifyPlanCountFrame} from './helpers/claude-pty-runner';
|
||
import captured from './fixtures/plan-count-edit-permission-t.json';
|
||
import capturedAc from './fixtures/plan-count-permission-ac.json';
|
||
|
||
function fixture() {
|
||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'count-file-epoch-'));const cwd=path.join(dir,'cwd');fs.mkdirSync(cwd);
|
||
const config=path.join(dir,'.claude');const expected=path.join(dir,'report.md');fs.writeFileSync(expected,'original');
|
||
const recorder=createFilePermissionRecorder(cwd,config,expected)!;const startedAt=Date.now()-1000;
|
||
const screen=captured.screen.replaceAll(captured.expectedPath,expected).replace('../gstack-e2e-plan-ceo-paired-2Rv5Bi/gstack-test-plan-ceo-paired.md','../report.md').replaceAll('gstack-test-plan-ceo-paired.md','report.md');
|
||
const transcript:any={status:'ready',calls:[],assistantMessages:[{sessionId:'main',text:'Reviewing',timestamp:new Date().toISOString()}]};
|
||
const event=(name:string,id:string,extra={})=>({hook_event_name:name,tool_name:'Edit',session_id:'main',tool_use_id:id,cwd,transcript_path:path.join(config,'projects','owned','main.jsonl'),tool_input:{file_path:expected,old_string:'NEVER_SAVE_OLD',new_string:'NEVER_SAVE_NEW'},...extra});
|
||
const record=(name:string,id:string,extra={})=>recordFilePermission(JSON.stringify(event(name,id,extra)),recorder.file,cwd,config,expected);
|
||
const read=()=>currentFilePermissionEpoch(recorder.file,expected,cwd,config,startedAt,transcript,screen);
|
||
return {dir,cwd,config,expected,recorder,startedAt,screen,transcript,event,record,read,close(){recorder.dispose();fs.rmSync(dir,{recursive:true,force:true});}};
|
||
}
|
||
describe('native repeated report permission identity',()=>{
|
||
test('captured prompt is recognized but unchanged visible-only dedup cannot release its second Edit',()=>{
|
||
expect(classifyPlanCountFrame(captured.screen)).toBe('permission');
|
||
const guard=createPlanCountPermissionGuard();const history='⎿ Wrote32lines\n';
|
||
expect(guard(captured.screen,history)).toBe('grant');
|
||
expect(guard(captured.screen,history)).toBe('handled');
|
||
expect(captured.pendingEditId).toBeNull();
|
||
});
|
||
test('late success and old pane redraw do not release until a distinct current request',()=>{
|
||
const f=fixture();try{
|
||
const guard=createPlanCountPermissionGuard();const input=()=>guard(f.screen,'',f.read());
|
||
expect(input()).toBe('handled');
|
||
f.record('PreToolUse','first');expect(input()).toBe('grant');expect(input()).toBe('handled');
|
||
f.record('PostToolUse','first');expect(input()).toBe('handled');
|
||
f.record('PreToolUse','first');expect(input()).toBe('handled');
|
||
f.record('PreToolUse','second');expect(input()).toBe('grant');expect(input()).toBe('handled');
|
||
f.record('PostToolUse','first');expect(input()).toBe('handled');
|
||
f.record('PostToolUse','second');expect(input()).toBe('handled');
|
||
f.record('PreToolUse','first');expect(input()).toBe('handled');
|
||
expect(fs.readFileSync(f.recorder.file,'utf8')).not.toContain('NEVER_SAVE');
|
||
}finally{f.close();}
|
||
});
|
||
test('failed, missing-result, foreign session/path, mismatched tool and replay cannot release',()=>{
|
||
for(const kind of ['failed','no-result','foreign','other-path','other-tool','sidechain','wrong-cwd']){
|
||
const f=fixture();try{
|
||
const guard=createPlanCountPermissionGuard();const input=()=>guard(f.screen,'',f.read());
|
||
f.record('PreToolUse','first');expect(input()).toBe('grant');
|
||
if(kind==='failed')f.record('PostToolUseFailure','first');
|
||
else if(kind!=='no-result')f.record('PostToolUse','first',kind==='foreign'?{session_id:'foreign'}:kind==='other-path'?{tool_input:{file_path:path.join(f.dir,'other')}}:kind==='other-tool'?{tool_name:'Bash'}:kind==='sidechain'?{agent_id:'child'}:{cwd:'/other'});
|
||
f.record('PreToolUse','second');expect(input(),kind).toBe('handled');
|
||
}finally{f.close();}
|
||
}
|
||
});
|
||
test('scoped current records and viewport are required; other permission policy stays unchanged',()=>{
|
||
const f=fixture();try{
|
||
f.record('PreToolUse','first');const valid=fs.readFileSync(f.recorder.file,'utf8');
|
||
for(const delta of [{sessionId:'foreign'},{cwd:'/other'},{expected:'/other'},{transcriptPath:'/outside/main.jsonl'},{timestamp:new Date(f.startedAt-1).toISOString()},{timestamp:new Date(Date.now()+60000).toISOString()},{pendingId:'main:../escape'}]){
|
||
fs.writeFileSync(f.recorder.file,JSON.stringify({...JSON.parse(valid),...delta}));expect(f.read()).toBeNull();
|
||
}
|
||
fs.writeFileSync(f.recorder.file,valid);
|
||
expect(currentFilePermissionEpoch(f.recorder.file,f.expected,f.cwd,f.config,f.startedAt,f.transcript,'Do you want to create OTHER.md?')).toBeUndefined();
|
||
const guard=createPlanCountPermissionGuard();expect(guard('Do you want to create OTHER.md?\n❯1.Yes\n2.Yes, and switch to accept edits\n3.No\nEsc to cancel · Tab to amend')).toBe('grant');
|
||
fs.unlinkSync(f.recorder.file);fs.symlinkSync(f.expected,f.recorder.file);expect(f.read()).toBeNull();
|
||
expect(createFilePermissionRecorder(f.cwd,f.config,path.parse(f.dir).root+'not-disposable.md')).toBeUndefined();
|
||
}finally{f.close();}
|
||
});
|
||
});
|
||
|
||
for (const variant of ['basic', 'intervening', 'cropped', 'same-basename', 'path-cropped']) test.skipIf(process.platform==='win32')(`real fake CLI grants each current request once: ${variant}`,async()=>{
|
||
const intervening = variant === 'intervening';
|
||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ce-'));const fake=path.join(dir,'fake-claude');const worker=path.join(dir,'worker.ts');const events=path.join(dir,'events.jsonl');const output=path.join(dir,'output.json');const expected=path.join(dir,variant==='same-basename'?'PLAN.md':'report.md');fs.writeFileSync(expected,'original');
|
||
const cropped=capturedAc.rows.find(row=>row.job===5)!;
|
||
let screen=variant==='path-cropped' ? capturedPath.screen.replace(capturedPath.screen.split('\n')[0]!,expected).replaceAll(path.dirname(capturedPath.expected),path.dirname(expected)).replaceAll(path.basename(capturedPath.expected),'report.md')
|
||
: variant==='cropped' ? cropped.screen.replaceAll(path.dirname(cropped.hook.expected),path.dirname(expected)).replaceAll(path.basename(cropped.hook.expected),'report.md')
|
||
: captured.screen.replaceAll(captured.expectedPath,expected).replace('../gstack-e2e-plan-ceo-paired-2Rv5Bi/gstack-test-plan-ceo-paired.md',expected).replaceAll('gstack-test-plan-ceo-paired.md','report.md');
|
||
if(variant==='same-basename')screen=screen.replaceAll(expected,'__ACTIVE_PLAN_PATH__').replaceAll('report.md','PLAN.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.FILE_EPOCH_CASE);const log=e=>fs.appendFileSync(item.events,JSON.stringify(e)+'\n');
|
||
const sid='epoch-main';const nativePath=path.join(process.env.CLAUDE_CONFIG_DIR,'projects','epoch',sid+'.jsonl');fs.mkdirSync(path.dirname(nativePath),{recursive:true});
|
||
const native=(role,content,extra={})=>fs.appendFileSync(nativePath,JSON.stringify({cwd:process.cwd(),sessionId:sid,isSidechain:false,timestamp:new Date().toISOString(),message:{role,content},...extra})+'\n');
|
||
native('assistant',[{type:'text',text:'Reviewing fixture.'}]);log({type:'start',pid:process.pid,cwd:process.cwd()});
|
||
const settings=JSON.parse(process.argv[process.argv.indexOf('--settings')+1]);
|
||
if(settings.hooks.PreToolUse[0].matcher!=='^ExitPlanMode$')throw Error('Exit recorder changed');
|
||
const hook=async(name,id)=>{
|
||
const entries=(settings.hooks[name]??[]).filter(h=>h.matcher==='^(Write|Edit)$');
|
||
for(const entry of entries){
|
||
const event={hook_event_name:name,tool_name:'Edit',session_id:sid,tool_use_id:id,cwd:process.cwd(),transcript_path:nativePath,tool_input:{file_path:item.activePlan?path.join(process.cwd(),'PLAN.md'):item.expected,old_string:'old',new_string:'new'}};
|
||
const p=Bun.spawn(['bash','-c',entry.hooks[0].command],{stdin:new Blob([JSON.stringify(event)]),stdout:'pipe',stderr:'pipe'});
|
||
const [code,out,err]=await Promise.all([p.exited,new Response(p.stdout).text(),new Response(p.stderr).text()]);if(code||out||err)throw Error('hook was not silent');log({type:'hook',name,id});
|
||
}
|
||
};
|
||
let stage='startup';const paint=()=>process.stdout.write('\x1b[2J\x1b[H'+item.screen.replaceAll('__ACTIVE_PLAN_PATH__',path.join(process.cwd(),'PLAN.md')).replaceAll('\n','\r\n'));
|
||
process.stdin.setRawMode?.(true);process.stdin.on('data',async data=>{
|
||
const input=data.toString();log({type:'input',stage,input});
|
||
if(stage==='startup'){stage='first';await hook('PreToolUse','first');paint();return;}
|
||
if(stage==='old-pane'||stage==='done'){log({type:'unexpected'});return;}
|
||
if(input!=='1\r')throw Error('default permission input changed');
|
||
if(stage==='first'){stage='old-pane';await hook('PostToolUse','first');if(item.intervening){await hook('PreToolUse','automatic');await hook('PostToolUse','automatic');}paint();setTimeout(async()=>{await hook('PreToolUse','second');stage='second';paint();},3200);return;}
|
||
stage='done';await hook('PostToolUse','second');
|
||
const q={header:'Finding',question:'Apply this repair?',options:[{label:'Fix'},{label:'Keep'}]};
|
||
native('assistant',[{type:'tool_use',name:'AskUserQuestion',id:'finding',input:{questions:[q]}}]);native('user',[{type:'tool_result',tool_use_id:'finding',content:'Answered'}],{toolUseResult:{answers:{[q.question]:'Fix'}}});
|
||
process.stdout.write('\x1b[2J\x1b[HDone.\r\n');
|
||
});process.on('SIGINT',()=>process.exit(0));process.stdin.resume();
|
||
`);fs.chmodSync(fake,0o755);
|
||
fs.writeFileSync(worker,`import {runPlanSkillCounting} from ${JSON.stringify(pathToFileURL(path.join(import.meta.dir,'helpers/claude-pty-runner.ts')).href)};const o=await runPlanSkillCounting({skillName:'plan-ceo-review',slashCommand:'/plan-ceo-review',followUpPrompt:'Review the disposable fixture.',expectedPlanPath:${JSON.stringify(expected)},isLastStep0AUQ:()=>false,isReviewAUQ:()=>true,reviewCountCeiling:1,timeoutMs:28000,env:{FILE_EPOCH_CASE:${JSON.stringify(JSON.stringify({events,expected,screen,intervening,activePlan:variant==='same-basename'}))}}});await Bun.write(${JSON.stringify(output)},JSON.stringify(o));`);
|
||
const child=Bun.spawn([process.execPath,worker],{env:{...process.env,BROWSE_TERMINAL_BINARY:fake,EVALS_HERMETIC:'1'},stdout:'pipe',stderr:'pipe'});const killer=setTimeout(()=>child.kill('SIGKILL'),33000);
|
||
try{const [code,out,err]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);expect(code,out+err).toBe(0);
|
||
const o=JSON.parse(fs.readFileSync(output,'utf8'));expect(o.outcome,JSON.stringify(o)).toBe('ceiling_reached');expect(o.reviewCount).toBe(1);
|
||
const rows=fs.readFileSync(events,'utf8').trim().split('\n').map(l=>JSON.parse(l));expect(rows.filter(e=>e.type==='input').map(e=>e.input)).toEqual(['/plan-ceo-review\r','1\r','1\r']);expect(rows.some(e=>e.type==='unexpected')).toBe(false);
|
||
expect(()=>process.kill(rows[0].pid,0)).toThrow();expect(fs.existsSync(rows[0].cwd)).toBe(false);
|
||
}finally{clearTimeout(killer);child.kill('SIGKILL');if(fs.existsSync(events)){const first=JSON.parse(fs.readFileSync(events,'utf8').split('\n')[0]!);try{process.kill(first.pid,'SIGKILL');}catch{}}fs.rmSync(dir,{recursive:true,force:true});}
|
||
},35000);
|
||
|
||
import capturedPath from './fixtures/plan-count-permission-target-ad-v2.json';
|
||
function pathCroppedFixture(){
|
||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'count-path-cropped-'));
|
||
// Retain the captured relative layout inside this run's disposable temp root.
|
||
const relocate=(value:string)=>value.replaceAll(path.dirname(capturedPath.cwd),dir);
|
||
const cwd=relocate(capturedPath.cwd),config=relocate(capturedPath.config),expected=relocate(capturedPath.expected);
|
||
const screen=relocate(capturedPath.screen),sessionId=capturedPath.sessionId;
|
||
const recorder=createFilePermissionRecorder(cwd,config,expected)!;const startedAt=Date.now()-1000;
|
||
const transcript:any={status:'ready',calls:[],assistantMessages:[{sessionId,text:'Reviewing',timestamp:new Date().toISOString()}]};
|
||
// The public screen/identity are retained; these hook events are synthetic.
|
||
const record=(name:string,id:string)=>recordFilePermission(JSON.stringify({hook_event_name:name,tool_name:'Edit',session_id:sessionId,tool_use_id:id,cwd,transcript_path:path.join(config,'projects','owned',sessionId+'.jsonl'),tool_input:{file_path:expected}}),recorder.file,cwd,config,expected);
|
||
const read=(viewport=screen)=>currentFilePermissionEpoch(recorder.file,expected,cwd,config,startedAt,transcript,viewport);
|
||
return {recorder,startedAt,transcript,record,read,screen,expected,close(){recorder.dispose();fs.rmSync(dir,{recursive:true,force:true});}};
|
||
}
|
||
test('AD v2 retained path-only heading needs the exact current owned epoch',()=>{
|
||
const f=pathCroppedFixture();try{
|
||
expect(capturedPath.provenance.actualOutcome).toBe('timeout');
|
||
expect(classifyPlanCountFrame(f.screen)).toBe('permission');
|
||
expect(f.read()).toBeNull();f.record('PreToolUse','first');
|
||
expect(f.read()?.pendingId).toBe(capturedPath.sessionId+':first');
|
||
const guard=createPlanCountPermissionGuard();expect(guard(f.screen,'',f.read())).toBe('grant');
|
||
expect(guard(f.screen,'',f.read())).toBe('handled');
|
||
}finally{f.close();}
|
||
});
|
||
|
||
test('AD v2 path-only target retains native success and replay boundaries',()=>{
|
||
const f=pathCroppedFixture();try{
|
||
const guard=createPlanCountPermissionGuard();const read=()=>guard(f.screen,'',f.read());
|
||
f.record('PreToolUse','first');expect(read()).toBe('grant');
|
||
f.record('PostToolUseFailure','first');f.record('PreToolUse','second');expect(read()).toBe('handled');
|
||
f.record('PostToolUse','second');f.record('PreToolUse','third');expect(read()).toBe('handled');
|
||
}finally{f.close();}
|
||
const g=pathCroppedFixture();try{
|
||
const guard=createPlanCountPermissionGuard();g.record('PreToolUse','first');expect(guard(g.screen,'',g.read())).toBe('grant');
|
||
g.record('PostToolUse','first');expect(guard(g.screen,'',g.read())).toBe('handled');
|
||
g.record('PreToolUse','second');expect(guard(g.screen,'',g.read())).toBe('grant');
|
||
g.record('PostToolUse','second');g.record('PreToolUse','first');expect(guard(g.screen,'',g.read())).toBe('handled');
|
||
}finally{g.close();}
|
||
});
|
||
|
||
test('AD v2 path-only heading must agree with full menu target and current metadata',()=>{
|
||
const f=pathCroppedFixture();try{
|
||
f.record('PreToolUse','first');const screen=f.screen,lines=screen.split('\n');
|
||
expect(f.read([f.expected,...lines.slice(1)].join('\n'))?.pendingId).toBe(capturedPath.sessionId+':first');
|
||
const wrong=[
|
||
[' ../sibling/'+path.basename(f.expected),...lines.slice(1)].join('\n'),
|
||
[' '+f.expected+' extra',...lines.slice(1)].join('\n'),
|
||
[lines[0],'/tmp/other/'+path.basename(f.expected),...lines.slice(1)].join('\n'),
|
||
[lines[0],...lines.slice(2)].join('\n'),
|
||
'Example:\n'+screen,'```text\n'+screen,screen+'\nContinue with a different action.',
|
||
screen.replace('❯ 1. Yes','❯ 2. Yes'),screen.replace('Esc to cancel · Tab to amend','Esc to cancel'),
|
||
screen.replace('always allow access to '+path.dirname(f.expected),'always allow access to /tmp/sibling'),
|
||
];
|
||
for(const altered of wrong){expect(altered).not.toBe(screen);expect(f.read(altered)).toBeNull();}
|
||
const valid=JSON.parse(fs.readFileSync(f.recorder.file,'utf8'));
|
||
for(const delta of [{timestamp:new Date(f.startedAt-1).toISOString()},{sessionId:'foreign'},{pendingId:valid.completedId},{cwd:'/foreign'},{expected:'/foreign'}]){
|
||
fs.writeFileSync(f.recorder.file,JSON.stringify({...valid,...delta}));expect(f.read()).toBeNull();
|
||
}
|
||
}finally{f.close();}
|
||
});
|
||
|
||
import {E2E_TOUCHFILES,selectTests} from './helpers/touchfiles';
|
||
test('AD v2 cropped target fixture selects all existing file-permission consumers',()=>{
|
||
expect(selectTests(['test/fixtures/plan-count-permission-target-ad-v2.json'],E2E_TOUCHFILES,[]).selected)
|
||
.toEqual(selectTests(['test/plan-count-file-permission.test.ts'],E2E_TOUCHFILES,[]).selected);
|
||
});
|