Files
gstack/test/plan-count-file-permission.test.ts
Garry Tan a6b3a57512 v1.87.4.0 fix: preserve health failures and disclose coverage (#2882)
* 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
2026-09-15 21:35:11 -07:00

182 lines
16 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,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?\n1.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);
});