mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-04 10:26:52 +02:00
test: fold per-incident replay series into their detector owners (D)
Twelve detector families move into one owner test each: 73 incident files become describe blocks in ceo-section-loading-fixture (stale-fill race), model-overlays, coverage-audit-evidence, autoplan-phase-observer, native-auto-decide, outside-voice-evidence, eng-first-review, plan-count-completion, plan-count-file-permission, ceo-mode-option, plan-scope-selection and plan-count-prerequisite. Each block keeps its original code and fixture, so every case still runs; only tests asserting the incident file's own touchfile registration are dropped (41). Touchfile lists that named an incident now name its owner.
This commit is contained in:
1 parent
6415690a18
commit
0489bc9fe8
89 files changed
+8091
-8373
No files matched your search
@@ -207,7 +207,672 @@ test('AD v2 path-only heading must agree with full menu target and current metad
|
||||
});
|
||||
|
||||
import {E2E_TOUCHFILES,selectTests} from './helpers/touchfiles';
|
||||
import fs_batching_permission_at from 'node:fs';
|
||||
import os_batching_permission_at from 'node:os';
|
||||
import path_batching_permission_at from 'node:path';
|
||||
import captured_batching_permission_at from './fixtures/batching-permission-at.json';
|
||||
import fixture_design_crop_gutter_ap from './fixtures/design-crop-gutter-ap.json';
|
||||
import previous_design_crop_gutter_ap from './fixtures/plan-count-crop-ak.json';
|
||||
import capturedAd_plan_count_permission_ac from './fixtures/plan-count-permission-ad.json';
|
||||
import capturedAe_plan_count_permission_ac from './fixtures/plan-count-permission-ae.json';
|
||||
import capturedAh_plan_count_permission_ac from './fixtures/plan-count-permission-ah.json';
|
||||
import { currentFilePermissionBinding } from './helpers/plan-count-file-permission';
|
||||
import exact_plan_count_quoted_frame_ak from './fixtures/plan-count-quoted-frame-ak.json';
|
||||
import owned_plan_count_quoted_frame_ak from './fixtures/plan-count-owned-permission-v.json';
|
||||
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);
|
||||
});
|
||||
|
||||
describe('batching-permission-at', () => {
|
||||
const fs = fs_batching_permission_at;
|
||||
const os = os_batching_permission_at;
|
||||
const path = path_batching_permission_at;
|
||||
const captured = captured_batching_permission_at;
|
||||
function renderPermissionScreen(expected: string, paths: Pick<typeof path, 'dirname' | 'basename'> = path): string {
|
||||
// The capture is already laid out at the runner's 120 columns. Replacing its
|
||||
// path must reflow that menu line, otherwise the PTY hard-wraps words in half.
|
||||
return captured.screen.split('\n').map(original => {
|
||||
const line = original.replaceAll(path.posix.dirname(captured.expectedPath), paths.dirname(expected))
|
||||
.replaceAll(path.posix.basename(captured.expectedPath), paths.basename(expected));
|
||||
if (line === original || line.length <= 120) return line;
|
||||
const indent = /^ */.exec(line)![0], lines: string[] = []; let current = indent;
|
||||
for (const word of line.trim().split(/\s+/)) {
|
||||
if (indent.length + word.length > 120) throw Error('Fixture path exceeds the permission panel width');
|
||||
if (current.length > indent.length && current.length + 1 + word.length > 120) { lines.push(current); current = indent; }
|
||||
current += (current.length > indent.length ? ' ' : '') + word;
|
||||
}
|
||||
return [...lines, current].join('\n');
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function fixture(){
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'batch-permission-')),cwd=path.join(dir,'cwd'),config=path.join(dir,'.claude'),expected=path.join(dir,'report.md');fs.mkdirSync(cwd);fs.writeFileSync(expected,'original');
|
||||
const recorder=createFilePermissionRecorder(cwd,config,expected)!;const startedAt=Date.now()-1000;
|
||||
const screen=renderPermissionScreen(expected);
|
||||
const transcript:any={status:'ready',calls:[],assistantMessages:[{sessionId:'synthetic-epoch',text:'Reviewing',timestamp:new Date().toISOString()}]};
|
||||
const record=(name:string,id:string,extra={})=>recordFilePermission(JSON.stringify({hook_event_name:name,tool_name:'Edit',session_id:'synthetic-epoch',tool_use_id:id,cwd,transcript_path:path.join(config,'projects','owned','synthetic-epoch.jsonl'),tool_input:{file_path:expected},...extra}),recorder.file,cwd,config,expected);
|
||||
const read=()=>currentFilePermissionEpoch(recorder.file,expected,cwd,config,startedAt,transcript,screen);
|
||||
return{dir,cwd,config,expected,recorder,screen,transcript,record,read,close(){recorder.dispose();fs.rmSync(dir,{recursive:true,force:true})}};
|
||||
}
|
||||
|
||||
test('retained retry has a valid permission panel and real previous completion without a pending ID',()=>{
|
||||
expect(classifyPlanCountFrame(captured.screen)).toBe('permission');expect(captured.priorCompletedEdit[0]!.name).toBe('Edit');expect(captured.priorCompletedEdit[1]!.isError).toBe(false);
|
||||
expect(captured.pendingEditId).toBeNull();expect(captured.provenance.originalOutcome).toBe('timeout');expect(captured.provenance.paidOutcomeReclassified).toBe(false);
|
||||
const guard=createPlanCountPermissionGuard();expect(guard(captured.screen,captured.lastMatchedDisplayCompletion)).toBe('grant');expect(guard(captured.screen,captured.lastMatchedDisplayCompletion)).toBe('handled');
|
||||
});
|
||||
|
||||
test('a substituted long fixture path reflows the menu without splitting permission words', () => {
|
||||
const prefix = ' always allow access to ', suffix = ' for this ';
|
||||
const directory = '/' + 'x'.repeat(120 - prefix.length - suffix.length - 3 - 1);
|
||||
const rawLine = `${prefix}${directory}${suffix}session`;
|
||||
expect(`${rawLine.slice(0, 120)}\n${rawLine.slice(120)}`).toContain('ses\nsion');
|
||||
for (const paths of [path.posix, path.win32]) {
|
||||
const expected = paths.join(directory, 'report.md');
|
||||
const screen = renderPermissionScreen(expected, paths);
|
||||
const menu = screen.slice(screen.indexOf(' Do you want to make this edit'));
|
||||
expect(menu.split('\n').every(line => line.length <= 120)).toBe(true);
|
||||
expect(menu).toContain(paths.dirname(expected));
|
||||
expect(menu).toContain('edit to report.md?');
|
||||
expect(menu).toMatch(/1\. Yes[\s\S]+2\. Yes,[\s\S]+3\. No/);
|
||||
expect(createPlanCountPermissionGuard()(screen, captured.lastMatchedDisplayCompletion)).toBe('grant');
|
||||
}
|
||||
});
|
||||
|
||||
test('synthetic hook epochs release only the later exact request after its predecessor succeeds',()=>{
|
||||
const f=fixture();try{const guard=createPlanCountPermissionGuard(),input=()=>guard(f.screen,captured.lastMatchedDisplayCompletion,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');
|
||||
}finally{f.close()}
|
||||
});
|
||||
for(const reason of ['failed','no-result','foreign-session','foreign-path','other-tool','sidechain'])test(`a later matching menu cannot replace ${reason} predecessor evidence`,()=>{
|
||||
const f=fixture();try{const guard=createPlanCountPermissionGuard(),input=()=>guard(f.screen,'',f.read());f.record('PreToolUse','first');expect(input()).toBe('grant');
|
||||
if(reason==='failed')f.record('PostToolUseFailure','first');else if(reason!=='no-result')f.record('PostToolUse','first',reason==='foreign-session'?{session_id:'foreign'}:reason==='foreign-path'?{tool_input:{file_path:path.join(f.dir,'foreign','report.md')}}:reason==='other-tool'?{tool_name:'Read'}:{agent_id:'child'});
|
||||
f.record('PreToolUse','second');expect(input()).toBe('handled');
|
||||
}finally{f.close()}
|
||||
});
|
||||
test.skipIf(process.platform==='win32')('real fake CLI observes two file epochs without imposing terminal report validation',async()=>{
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'batch-permission-pty-')),fake=path.join(dir,'fake-claude'),worker=path.join(dir,'worker.ts'),events=path.join(dir,'events.jsonl'),output=path.join(dir,'output.json'),expected=path.join(dir,'report.md');fs.writeFileSync(expected,'original');
|
||||
const screen=renderPermissionScreen(expected);
|
||||
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)$');
|
||||
if(entries.length!==1)throw Error('Expected exactly one caller-owned file recorder');
|
||||
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[HCompletion summary\r\n');
|
||||
});process.on('SIGINT',()=>process.exit(0));process.stdin.resume();process.stdout.write('FILE_EPOCH_READY\r\n');
|
||||
`);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-eng-review',slashCommand:'/plan-eng-review',followUpPrompt:'Review this disposable batching fixture.',permissionPlanPath:${JSON.stringify(expected)},startupReadyMarker:'FILE_EPOCH_READY',isLastStep0AUQ:()=>false,isReviewAUQ:()=>true,reviewCountCeiling:2,timeoutMs:28000,env:{FILE_EPOCH_CASE:${JSON.stringify(JSON.stringify({events,expected,screen}))}}});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('completion_summary');expect(o.reviewCount).toBe(1);expect(fs.readFileSync(expected,'utf8')).toBe('original');
|
||||
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-eng-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);
|
||||
});
|
||||
|
||||
describe('design-crop-gutter-ap', () => {
|
||||
const fs = fs_batching_permission_at;
|
||||
const os = os_batching_permission_at;
|
||||
const path = path_batching_permission_at;
|
||||
const fixture = fixture_design_crop_gutter_ap;
|
||||
const previous = previous_design_crop_gutter_ap;
|
||||
function replay(change:(f:any)=>void=()=>{},input:any=fixture){
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'design-gutter-ap-'));
|
||||
const expected=path.join(dir,'report.md'),record=path.join(dir,'record.json');
|
||||
const f:any={expected,record,cwd:input.cwd,config:input.config,startedAt:input.startedAt,
|
||||
screen:input.screen.replaceAll(path.dirname(input.hook.expected),dir).replaceAll(path.basename(input.hook.expected),'report.md'),
|
||||
state:{...structuredClone(input.hook),expected},before:input.ownedBefore,transcript:structuredClone(input.transcript),fileKind:'file'};
|
||||
try{
|
||||
change(f);fs.writeFileSync(record,JSON.stringify(f.state));
|
||||
if(f.fileKind==='file')fs.writeFileSync(expected,f.before);
|
||||
if(f.fileKind==='directory')fs.mkdirSync(expected);
|
||||
if(f.fileKind==='symlink'){const target=path.join(dir,'other.md');fs.writeFileSync(target,f.before);fs.symlinkSync(target,expected);}
|
||||
const epoch=currentFilePermissionEpoch(record,f.expected,f.cwd,f.config,f.startedAt,f.transcript,f.screen);
|
||||
const guard=createPlanCountPermissionGuard();
|
||||
return {epoch,first:guard(f.screen,'',epoch),second:guard(f.screen,'',epoch)};
|
||||
}finally{fs.rmSync(dir,{recursive:true,force:true});}
|
||||
}
|
||||
|
||||
test('exact five-column current gutter supplies one owned Edit epoch and one grant',()=>{
|
||||
const lines=fixture.screen.split('\n');expect(lines[0]).toMatch(/^ {5}\S/);expect(lines[1]).toBe(' 62 ');
|
||||
expect(fixture.ownedBefore.split('\n')[60]!.endsWith(lines[0]!.slice(5))).toBe(true);
|
||||
const r=replay();expect(r.epoch).toEqual({pendingId:fixture.hook.pendingId,completedId:fixture.hook.completedId,completedIds:fixture.hook.completedIds});
|
||||
expect(r.first).toBe('grant');expect(r.second).toBe('handled');
|
||||
});
|
||||
|
||||
test('prior six-column public crop remains exact and one-time',()=>{
|
||||
expect(previous.screen.split('\n')[0]).toMatch(/^ {6}\S/);expect(previous.screen.split('\n')[1]).toBe(' 82 ');
|
||||
const r=replay(()=>{},previous);expect(r.epoch?.pendingId).toBe(previous.hook.pendingId);expect(r.first).toBe('grant');expect(r.second).toBe('handled');
|
||||
// Keep the existing six-space acceptance even when the adjacent numeric
|
||||
// row has a different padding; the unchanged original-line guard remains.
|
||||
expect(replay(f=>{f.screen=' '+f.screen;}).epoch?.pendingId).toBe(fixture.hook.pendingId);
|
||||
});
|
||||
|
||||
test('padding and line-number width derive the continuation column together',()=>{
|
||||
const padded=replay(f=>{f.screen=' '+f.screen;f.screen=f.screen.replace(/^ 62 $/m,' 62 ');});
|
||||
expect(padded.epoch?.pendingId).toBe(fixture.hook.pendingId);
|
||||
const relocated=replay(f=>{
|
||||
f.before='Earlier unchanged line\n'.repeat(38)+f.before;
|
||||
f.screen=' '+f.screen;
|
||||
f.screen=f.screen.replace(/^ ([1-9]\d*)( | [+-])/gm,(_:string,n:string,g:string)=>' '+(Number(n)+38)+g);
|
||||
});
|
||||
expect(relocated.epoch?.pendingId).toBe(fixture.hook.pendingId);
|
||||
});
|
||||
|
||||
test.each([0,3])('a native numbered-row padding of %d derives a matching non-six gutter',padding=>{
|
||||
const r=replay(f=>{
|
||||
f.screen=' '.repeat(padding+4)+f.screen.slice(5);
|
||||
f.screen=f.screen.replace(/^ 62 $/m,' '.repeat(padding)+'62 ');
|
||||
});
|
||||
expect(r.epoch?.pendingId).toBe(fixture.hook.pendingId);expect(r.first).toBe('grant');
|
||||
});
|
||||
|
||||
test('an ordinary numbered unchanged row remains a numbered row, not a wrapped continuation',()=>{
|
||||
const r=replay(f=>{
|
||||
f.screen=' 61 '+f.before.split('\n')[60]+'\n'+f.screen.slice(f.screen.indexOf('\n')+1);
|
||||
});
|
||||
expect(r.epoch?.pendingId).toBe(fixture.hook.pendingId);expect(r.first).toBe('grant');
|
||||
});
|
||||
|
||||
const negatives:Array<[string,(f:any)=>void]>=[
|
||||
['four-space gutter with five-column numbered row',f=>{f.screen=f.screen.slice(1);}],
|
||||
['seven-space gutter with five-column numbered row',f=>{f.screen=' '+f.screen;}],
|
||||
['tab cannot substitute for a native space gutter',f=>{f.screen='\t'+f.screen.slice(1);}],
|
||||
['wrong preceding file line',f=>{f.screen=f.screen.replace(/^ 62 $/m,' 63 ');}],
|
||||
['changed continuation content',f=>{f.screen=f.screen.replace('f2 with icon','foreign with icon');}],
|
||||
['stale before-file bytes',f=>{f.before=f.before.replace('f2 with icon','changed with icon');}],
|
||||
['quoted continuation',f=>{f.screen=f.screen.replace(/^ {5}/,' > ');}],
|
||||
['two unnumbered continuation rows',f=>{f.screen=f.screen.split('\n')[0]+'\n'+f.screen;}],
|
||||
['next row is an addition, not unchanged context',f=>{f.screen=f.screen.replace(/^ 62 $/m,' 62 +');}],
|
||||
['zero next line',f=>{f.screen=f.screen.replace(/^ 62 $/m,' 00 ');}],
|
||||
['missing current file',f=>{f.fileKind='missing';}],
|
||||
['directory instead of current file',f=>{f.fileKind='directory';}],
|
||||
['required source line beyond the bounded prefix',f=>{f.before='x'.repeat(65537)+f.before;}],
|
||||
['foreign displayed directory',f=>{f.screen=f.screen.replace(path.dirname(f.expected)+' for this session',path.join(path.dirname(f.expected),'foreign')+' for this session');}],
|
||||
['foreign hook target',f=>{f.state.expected+='.foreign';}],
|
||||
['foreign hook cwd',f=>{f.state.cwd+='.foreign';}],
|
||||
['foreign native session',f=>{f.transcript={status:'ready',calls:[],assistantMessages:[{sessionId:'foreign',text:'Current review',timestamp:new Date(f.startedAt).toISOString()}]};}],
|
||||
['missing pending request',f=>{f.state.pendingId=null;}],
|
||||
['completed request cannot reopen',f=>{f.state.completedId=f.state.pendingId;}],
|
||||
['stale request timestamp',f=>{f.state.timestamp=new Date(f.startedAt-1).toISOString();}],
|
||||
['missing menu footer',f=>{f.screen=f.screen.replace('Esc to cancel · Tab to amend','');}],
|
||||
['one-time action changed',f=>{f.screen=f.screen.replace('❯ 1. Yes','❯ 1. Yes, always allow');}],
|
||||
];
|
||||
test.each(negatives)('%s cannot obtain a grant',(_,change)=>{
|
||||
const r=replay(change);expect(r.epoch).not.toBeTruthy();expect(r.first).not.toBe('grant');
|
||||
});
|
||||
test.skipIf(process.platform==='win32')('symlink cannot provide the original line',()=>{expect(replay(f=>{f.fileKind='symlink';}).epoch).toBeNull();});
|
||||
});
|
||||
|
||||
describe('plan-count-crop-ak', () => {
|
||||
const fs = fs_batching_permission_at;
|
||||
const os = os_batching_permission_at;
|
||||
const path = path_batching_permission_at;
|
||||
const fixture = previous_design_crop_gutter_ap;
|
||||
function replay(change: (f: any) => void = () => {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-crop-ak-'));
|
||||
const expected = path.join(dir, 'report.md'), record = path.join(dir, 'record.json');
|
||||
const f: any = { expected, record, screen: fixture.screen.replaceAll(path.dirname(fixture.hook.expected), dir)
|
||||
.replaceAll(path.basename(fixture.hook.expected), 'report.md'), state: { ...structuredClone(fixture.hook), expected },
|
||||
before: fixture.ownedBefore, cwd: fixture.cwd, config: fixture.config, startedAt: fixture.startedAt,
|
||||
transcript: structuredClone(fixture.transcript), fileKind: 'file' };
|
||||
try {
|
||||
change(f); fs.writeFileSync(record, JSON.stringify(f.state));
|
||||
if (f.fileKind === 'file') fs.writeFileSync(expected, f.before);
|
||||
if (f.fileKind === 'directory') fs.mkdirSync(expected);
|
||||
if (f.fileKind === 'symlink') { const other = path.join(dir, 'other.md'); fs.writeFileSync(other, f.before); fs.symlinkSync(other, expected); }
|
||||
const epoch = currentFilePermissionEpoch(record, f.expected, f.cwd, f.config, f.startedAt, f.transcript, f.screen);
|
||||
return { epoch, screen: f.screen, guard: createPlanCountPermissionGuard(), run: () => createPlanCountPermissionGuard()(f.screen, '', epoch) };
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
test('actual owned bare continuation uses the preceding original line and grants once', () => {
|
||||
const r = replay();
|
||||
expect(r.epoch?.pendingId).toBe(fixture.hook.pendingId);
|
||||
expect(r.guard(r.screen, '', r.epoch)).toBe('grant');
|
||||
expect(r.guard(r.screen, '', r.epoch)).toBe('handled');
|
||||
});
|
||||
|
||||
test('harmless preceding line number relocation keeps the exact content binding', () => {
|
||||
const r = replay(f => {
|
||||
f.before = 'Added unchanged line\n' + f.before;
|
||||
f.screen = f.screen.replace(/^ {0,3}82 +$/m, ' 83 ');
|
||||
});
|
||||
expect(r.epoch?.pendingId).toBe(fixture.hook.pendingId);
|
||||
});
|
||||
|
||||
const negatives: Array<[string, (f: any) => void]> = [
|
||||
['wrong preceding line', f => { f.screen = f.screen.replace(/^ {0,3}82 +$/m, ' 83 '); }],
|
||||
['unrelated prose continuation', f => { f.screen = f.screen.replace(/^.*\n/, ' Apply this edit now\n'); }],
|
||||
['source quotation continuation', f => { f.screen = f.screen.replace(/^.*\n/, ' > Example: apply this edit now\n'); }],
|
||||
['foreign continuation suffix', f => { f.screen = f.screen.replace('f2 (~7.6:1)', 'foreign (~7.6:1)'); }],
|
||||
['five-space unnumbered gutter', f => { f.screen = f.screen.slice(1); }],
|
||||
['seven-space unnumbered gutter', f => { f.screen = ' ' + f.screen; }],
|
||||
['no following unchanged numbered row', f => { f.screen = f.screen.replace(/^ {0,3}82 +$/m, ' 82 +'); }],
|
||||
['two arbitrary continuation rows', f => { f.screen = f.screen.split('\n')[0] + '\n' + f.screen; }],
|
||||
['missing original file', f => { f.fileKind = 'missing'; }],
|
||||
['directory in place of original file', f => { f.fileKind = 'directory'; }],
|
||||
['required original line beyond the bounded prefix', f => { f.before = 'x'.repeat(65537) + f.before; }],
|
||||
['foreign displayed directory', f => { f.screen = f.screen.replace(path.dirname(f.expected) + ' for this session', path.join(path.dirname(f.expected), 'foreign') + ' for this session'); }],
|
||||
['foreign hook expected path', f => { f.state.expected += '.foreign'; }],
|
||||
['no current native request', f => { f.state.pendingId = null; }],
|
||||
['completed native request', f => { f.state.completedId = f.state.pendingId; }],
|
||||
['stale native timestamp', f => { f.state.timestamp = new Date(f.startedAt - 1).toISOString(); }],
|
||||
['future native timestamp', f => { f.state.timestamp = new Date(Date.now() + 60000).toISOString(); }],
|
||||
['foreign session transcript', f => { f.transcript.assistantMessages[0].sessionId = 'foreign'; }],
|
||||
['unavailable native transcript', f => { f.transcript.status = 'error'; }],
|
||||
['missing complete menu footer', f => { f.screen = f.screen.replace('Esc to cancel · Tab to amend', ''); }],
|
||||
['selected policy change', f => { f.screen = f.screen.replace('❯ 1. Yes', '❯ 1. Yes, always allow'); }],
|
||||
['unrelated prompt prepended', f => { f.screen = '☐ Review this example\n' + f.screen; }],
|
||||
['historical pane is not current viewport', f => { f.screen = 'Waiting for current tool'; }],
|
||||
];
|
||||
test.each(negatives)('%s cannot supply an owned epoch', (_, change) => {
|
||||
const r = replay(change); expect(r.epoch).not.toBeTruthy(); expect(r.run()).not.toBe('grant');
|
||||
});
|
||||
test.skipIf(process.platform === 'win32')('a symlink cannot supply current original content', () => {
|
||||
expect(replay(f => { f.fileKind = 'symlink'; }).epoch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan-count-permission-ac', () => {
|
||||
const captured = capturedAc;
|
||||
const capturedAd = capturedAd_plan_count_permission_ac;
|
||||
const capturedAe = capturedAe_plan_count_permission_ac;
|
||||
const capturedAh = capturedAh_plan_count_permission_ac;
|
||||
function fixture() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'count-permission-ac-'));
|
||||
const cwd = path.join(dir, 'cwd'), config = path.join(dir, '.claude');
|
||||
const expected = path.join(dir, 'report.md'), file = path.join(dir, 'state.json');
|
||||
fs.mkdirSync(cwd); const startedAt = Date.now() - 1000;
|
||||
const screen = captured.rows[0]!.screen
|
||||
.replace('../gstack-e2e-plan-design-3vPM9g/gstack-test-plan-design.md', expected)
|
||||
.replaceAll('gstack-test-plan-design.md', 'report.md');
|
||||
const transcript: any = { status: 'ready', calls: [], assistantMessages: [{ sessionId: 'main', text: 'Reviewing' }] };
|
||||
const record = (name: string, id: string, delta: object = {}) => recordFilePermission(JSON.stringify({
|
||||
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: 'PRIVATE_OLD', new_string: 'PRIVATE_NEW' }, ...delta,
|
||||
}), file, cwd, config, expected);
|
||||
const epoch = () => currentFilePermissionEpoch(file, expected, cwd, config, startedAt, transcript, screen);
|
||||
return { dir, cwd, config, expected, file, screen, record, epoch,
|
||||
close: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('all five actual stalled screens already identify an edit permission', () => {
|
||||
for (const row of captured.rows) {
|
||||
expect(classifyPlanCountFrame(row.screen), row.attempt).toBe('permission');
|
||||
expect(createPlanCountPermissionGuard()(row.screen), row.attempt).toBe('grant');
|
||||
expect(row.hook.pendingId).not.toBeNull();
|
||||
expect(row.transcriptSessions).toEqual([row.hook.sessionId]);
|
||||
}
|
||||
});
|
||||
|
||||
test('an intervening successful edit does not erase the exact previous grant completion', () => {
|
||||
const f = fixture(); try {
|
||||
const guard = createPlanCountPermissionGuard(), input = () => guard(f.screen, '', f.epoch());
|
||||
f.record('PreToolUse', 'granted'); expect(input()).toBe('grant');
|
||||
f.record('PostToolUse', 'granted'); expect(input()).toBe('handled');
|
||||
f.record('PreToolUse', 'automatic'); f.record('PostToolUse', 'automatic');
|
||||
// Old pane is still inert, even after two successful results.
|
||||
expect(input()).toBe('handled');
|
||||
f.record('PreToolUse', 'next'); expect(input()).toBe('grant'); expect(input()).toBe('handled');
|
||||
expect(fs.readFileSync(f.file, 'utf8')).not.toContain('PRIVATE_');
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('an unrelated success cannot substitute for failed or missing prior approval completion', () => {
|
||||
for (const outcome of ['PostToolUseFailure', 'missing', 'foreign', 'other-path', 'sidechain']) {
|
||||
const f = fixture(); try {
|
||||
const guard = createPlanCountPermissionGuard(), input = () => guard(f.screen, '', f.epoch());
|
||||
f.record('PreToolUse', 'granted'); expect(input()).toBe('grant');
|
||||
if (outcome === 'PostToolUseFailure') f.record(outcome, 'granted');
|
||||
else if (outcome !== 'missing') f.record('PostToolUse', 'granted', outcome === 'foreign'
|
||||
? { session_id: 'foreign' } : outcome === 'sidechain' ? { agent_id: 'child' }
|
||||
: { tool_input: { file_path: path.join(f.dir, 'other.md') } });
|
||||
f.record('PreToolUse', 'automatic'); f.record('PostToolUse', 'automatic');
|
||||
f.record('PreToolUse', 'next'); expect(input(), outcome).toBe('handled');
|
||||
f.record('PreToolUse', 'granted'); expect(input(), outcome).toBe('handled');
|
||||
} finally { f.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('success history rejects malformed, foreign, replayed, and pending IDs', () => {
|
||||
const f = fixture(); try {
|
||||
f.record('PreToolUse', 'first'); f.record('PostToolUse', 'first'); f.record('PreToolUse', 'next');
|
||||
const original = JSON.parse(fs.readFileSync(f.file, 'utf8'));
|
||||
for (const completedIds of [['foreign:first'], ['main:../escape'], ['main:next'],
|
||||
['main:first', 'main:first'], Array(129).fill('main:first'), ['main:unseen'], 'main:first']) {
|
||||
fs.writeFileSync(f.file, JSON.stringify({ ...original, completedIds }));
|
||||
expect(f.epoch()).toBeNull();
|
||||
}
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('success history is bounded by the existing 128-request recorder limit', () => {
|
||||
const f = fixture(); try {
|
||||
for (let i = 0; i < 127; i++) { f.record('PreToolUse', `id${i}`); f.record('PostToolUse', `id${i}`); }
|
||||
f.record('PreToolUse', 'last'); expect(f.epoch()?.completedIds?.length).toBe(127);
|
||||
expect(fs.statSync(f.file).size).toBeLessThan(64 * 1024);
|
||||
f.record('PreToolUse', 'overflow'); expect(f.epoch()).toBeNull();
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('cropped actual panes bind their full directory and basename to the current native epoch', () => {
|
||||
const rows = captured.rows.filter(row => [4, 5].includes(row.job) && !/^ {0,3}Edit file$/m.test(row.screen));
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
const f = fixture(); try {
|
||||
const screen = row.screen.replaceAll(path.dirname(row.hook.expected), path.dirname(f.expected))
|
||||
.replaceAll(path.basename(row.hook.expected), path.basename(f.expected));
|
||||
const read = (value = screen) => currentFilePermissionEpoch(f.file, f.expected, f.cwd, f.config,
|
||||
0, { status: 'ready', calls: [], assistantMessages: [{ sessionId: 'main', text: 'Reviewing', timestamp: new Date().toISOString() }] }, value);
|
||||
f.record('PreToolUse', 'current');
|
||||
expect(read()?.pendingId, row.attempt).toBe('main:current');
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
expect(guard(screen, '', read())).toBe('grant');
|
||||
expect(guard(screen, '', read())).toBe('handled');
|
||||
for (const [name, changed] of [
|
||||
['foreign', screen.replace(path.dirname(f.expected), path.join(f.dir, 'foreign'))],
|
||||
['remedy', screen.replace(/always\s+allow\s+access\s+to/, 'remove files from')],
|
||||
['footer', screen.replace('Esc to cancel · Tab to amend', '')],
|
||||
['yes policy', screen.replace(/❯\s*1\.\s*Yes/, '❯ 1. Yes, change policy')],
|
||||
['AUQ', '☐ Finding\n' + screen],
|
||||
['quoted', '> Example:\n' + screen],
|
||||
['wrapped path', screen.replace(path.dirname(f.expected), path.dirname(f.expected) + '\n/other')],
|
||||
['path spaces', screen.replace(path.dirname(f.expected), path.dirname(f.expected) + ' space')],
|
||||
]) {
|
||||
expect(changed, name).not.toBe(screen);
|
||||
expect(read(changed), name).toBeNull();
|
||||
}
|
||||
expect(read(screen.replace(path.dirname(f.expected), path.join(f.dir, 'foreign')))).toBeNull();
|
||||
f.record('PostToolUse', 'current'); expect(read()).toBeNull();
|
||||
f.record('PreToolUse', 'current'); expect(read()).toBeNull();
|
||||
} finally { f.close(); }
|
||||
}
|
||||
});
|
||||
test('a later exact owned binding wins over an earlier same-basename block', () => {
|
||||
const f = fixture(); try {
|
||||
f.record('PreToolUse', 'current');
|
||||
const transcript: any = {status:'ready', calls:[], assistantMessages:[{sessionId:'main', text:'Reviewing'}]};
|
||||
const foreign = {file:path.join(f.dir,'foreign-state.json'), expected:path.join(f.dir,'other','report.md')};
|
||||
const owned = {file:f.file, expected:f.expected};
|
||||
for (const bindings of [[foreign, owned], [owned, foreign]]) {
|
||||
const selected = currentFilePermissionBinding(bindings, f.cwd, f.config, 0, transcript, f.screen);
|
||||
expect(selected?.binding).toBe(owned);
|
||||
expect(selected?.epoch.pendingId).toBe('main:current');
|
||||
}
|
||||
const blocked = currentFilePermissionBinding([foreign, {...foreign, expected:path.join(f.dir,'another','report.md')}],
|
||||
f.cwd, f.config, 0, transcript, f.screen);
|
||||
expect(blocked).toBeNull();
|
||||
expect(createPlanCountPermissionGuard()(f.screen, '', blocked)).toBe('handled');
|
||||
const otherScreen = f.screen.replaceAll('report.md', 'OTHER.md');
|
||||
const unrelated = currentFilePermissionBinding([foreign, owned], f.cwd, f.config, 0, transcript, otherScreen);
|
||||
expect(unrelated).toBeUndefined();
|
||||
expect(createPlanCountPermissionGuard()(otherScreen, '', unrelated)).toBe('grant');
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
// Exact current screens plus content-free native identity from full AD/AE runs.
|
||||
// The replay projections do not assert these pending writes ever completed.
|
||||
const cases = [...capturedAd.rows, capturedAe, capturedAh].map(row => ({
|
||||
p: row, screen: row.screen, binding: {expected: row.state.expected, state: row.state},
|
||||
observation: {transcript: {status: row.transcriptStatus, calls: [],
|
||||
assistantMessages: row.transcriptSessions.map(sessionId => ({sessionId}))}},
|
||||
}));
|
||||
function adEpoch(c: any, screen = c.screen, state = c.binding.state, delta: any = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'permission-crop-replay-'));
|
||||
const file = path.join(dir, 'state.json');
|
||||
try {
|
||||
// Captures contain POSIX paths. Project their filesystem identity onto the
|
||||
// replay host without changing the captured fixture or its menu rendering.
|
||||
const nativeState = { ...state, cwd: path.resolve(state.cwd), expected: path.resolve(state.expected),
|
||||
transcriptPath: path.resolve(state.transcriptPath) };
|
||||
const replayScreen = screen.replaceAll(path.posix.dirname(c.binding.expected),
|
||||
path.dirname(path.resolve(c.binding.expected)));
|
||||
fs.writeFileSync(file, JSON.stringify(nativeState));
|
||||
return currentFilePermissionEpoch(file, path.resolve(delta.expected ?? c.binding.expected), path.resolve(delta.cwd ?? c.p.cwd),
|
||||
path.resolve(delta.config ?? c.p.config), delta.startedAt ?? c.p.startUnix * 1000,
|
||||
delta.transcript ?? c.observation.transcript, replayScreen);
|
||||
} finally { fs.rmSync(dir, {recursive:true, force:true}); }
|
||||
}
|
||||
for (const c of cases) {
|
||||
test(`actual AD captured current permission ${c.p.pid} returns its exact pending epoch`, () => {
|
||||
expect(classifyPlanCountFrame(c.screen)).toBe('permission');
|
||||
expect(adEpoch(c)).toEqual({pendingId:c.binding.state.pendingId, completedId:c.binding.state.completedId,
|
||||
completedIds:c.binding.state.completedIds});
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
expect(guard(c.screen, '', adEpoch(c))).toBe('grant');
|
||||
expect(guard(c.screen, '', adEpoch(c))).toBe('handled');
|
||||
});
|
||||
test(`AD isolating the rejected rendering guard ${c.p.pid} preserves native identity`, () => {
|
||||
// These are explicitly normalized controls; the actual captured screen is unchanged above.
|
||||
const normalized = c.p.pid === capturedAe.pid ? c.screen.replace(/^[╌─━]{3,}[ \t]*\n/, '')
|
||||
: c.p.pid === 1332470 ? c.screen.replace('3. Nohift+tab)', '3. No') : c.screen.replace(/^ {4,5}\+/, ' 99 +');
|
||||
expect(normalized).not.toBe(c.screen);
|
||||
expect(adEpoch(c, normalized)?.pendingId).toBe(c.binding.state.pendingId);
|
||||
});
|
||||
}
|
||||
for (const c of cases) {
|
||||
test(`AD crop ${c.p.pid} rejects foreign, quoted, incomplete and policy-changing menus`, () => {
|
||||
const directory = path.dirname(c.binding.expected);
|
||||
const changes: [string, string][] = [
|
||||
['foreign directory', c.screen.replace(directory, path.join(directory, 'foreign'))],
|
||||
['split directory', c.screen.replace(directory, directory + '\n/foreign')],
|
||||
['quoted', '> Example:\n' + c.screen],
|
||||
['AUQ', '☐ Review\n' + c.screen],
|
||||
['code fence', '```\n' + c.screen],
|
||||
['missing footer', c.screen.replace('Esc to cancel · Tab to amend', '')],
|
||||
['policy on selected Yes', c.screen.replace('❯ 1. Yes', '❯ 1. Yes, always allow')],
|
||||
['selected No', c.screen.replace('❯ 1. Yes', ' 1. Yes').replace(' 3. No', ' ❯ 3. No')],
|
||||
['arbitrary No suffix', c.screen.replace(/3\. No(?:hift\+tab\))?/, '3. No; run another command')],
|
||||
['another hint', c.screen.replace(/3\. No(?:hift\+tab\))?/, '3. No(shift+enter)')],
|
||||
['foreign option action', c.screen.replace(/always\s+allow\s+access\s+to/, 'delete files from')],
|
||||
['unrecognized cropped prose', c.screen.replace(/^.*\n/, 'arbitrary text\n')],
|
||||
];
|
||||
for (const [name, screen] of changes) {
|
||||
expect(screen, name).not.toBe(c.screen);
|
||||
expect(adEpoch(c, screen), name).not.toBeTruthy();
|
||||
expect(createPlanCountPermissionGuard()(screen, '', adEpoch(c, screen)), name).not.toBe('grant');
|
||||
}
|
||||
});
|
||||
test(`AD crop ${c.p.pid} leaves unrelated-basename permission policy unchanged`, () => {
|
||||
const screen = c.screen.replace(path.basename(c.binding.expected), 'OTHER.md');
|
||||
expect(adEpoch(c, screen)).toBeUndefined();
|
||||
// This is intentionally the existing caller policy for unrelated fixture permissions.
|
||||
expect(createPlanCountPermissionGuard()(screen, '', adEpoch(c, screen))).toBe('grant');
|
||||
});
|
||||
test(`AD crop ${c.p.pid} cannot replace missing, stale, completed or foreign native identity`, () => {
|
||||
const original = c.binding.state;
|
||||
for (const [name, state, delta] of [
|
||||
['foreign session', {...original, sessionId:'foreign'}, {}],
|
||||
['wrong native transcript', {...original, transcriptPath:path.join(c.p.config, 'projects', 'foreign', 'other.jsonl')}, {}],
|
||||
['completed request', {...original, completedId:original.pendingId}, {}],
|
||||
['no pending request', {...original, pendingId:null}, {}],
|
||||
['unseen pending request', {...original, pendingId:original.sessionId + ':other'}, {}],
|
||||
['stale timestamp', {...original, timestamp:new Date(c.p.startUnix * 1000 - 1).toISOString()}, {}],
|
||||
['future timestamp', {...original, timestamp:new Date(Date.now() + 60_000).toISOString()}, {}],
|
||||
['mixed sessions', original, {transcript:{status:'ready', calls:[], assistantMessages:[{sessionId:original.sessionId},{sessionId:'foreign'}]}}],
|
||||
['unready transcript', original, {transcript:{...c.observation.transcript,status:'unavailable'}}],
|
||||
] as const) {
|
||||
expect(adEpoch(c, c.screen, state, delta), name).toBeNull();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('AD crop fixture selects the exact existing permission regression callers', () => {
|
||||
expect(selectTests(['test/fixtures/plan-count-permission-ad.json'], E2E_TOUCHFILES).selected.sort()).toEqual(
|
||||
selectTests(['test/fixtures/plan-count-permission-ac.json'], E2E_TOUCHFILES).selected.sort());
|
||||
});
|
||||
|
||||
test('AE crop admits one native divider only and preserves its exact existing caller selection', () => {
|
||||
const c = cases.find(item => item.p.pid === capturedAe.pid)!;
|
||||
const firstLine = c.screen.slice(0, c.screen.indexOf('\n') + 1);
|
||||
for (const screen of [firstLine + c.screen, 'unrelated prose\n' + c.screen,
|
||||
firstLine + 'Example:\n' + c.screen.slice(firstLine.length),
|
||||
c.screen.replace(firstLine, firstLine.trimEnd() + ' extra action\n')]) {
|
||||
expect(adEpoch(c, screen)).toBeNull();
|
||||
expect(createPlanCountPermissionGuard()(screen, '', adEpoch(c, screen))).not.toBe('grant');
|
||||
}
|
||||
expect(selectTests(['test/fixtures/plan-count-permission-ae.json'], E2E_TOUCHFILES).selected.sort()).toEqual(
|
||||
selectTests(['test/fixtures/plan-count-permission-ad.json'], E2E_TOUCHFILES).selected.sort());
|
||||
});
|
||||
|
||||
test('AH wrapped crop admits four or five spaces with the same owned native epoch', () => {
|
||||
const c = cases.find(item => item.p.pid === capturedAh.pid)!;
|
||||
expect(c.screen.startsWith(' + ')).toBe(true);
|
||||
for (const screen of [c.screen, ` ${c.screen}`, c.screen.replace(/^ \+/, ' -')]) {
|
||||
expect(adEpoch(c, screen)?.pendingId).toBe(c.binding.state.pendingId);
|
||||
const guard = createPlanCountPermissionGuard();
|
||||
expect(guard(screen, '', adEpoch(c, screen))).toBe('grant');
|
||||
expect(guard(screen, '', adEpoch(c, screen))).toBe('handled');
|
||||
}
|
||||
// Cleaning the unselected No paint residue does not establish missing identity.
|
||||
const noOnly = c.screen.replace('3. Nohift+tab)', '3. No');
|
||||
expect(noOnly).not.toBe(c.screen);
|
||||
expect(adEpoch(c, noOnly)?.pendingId).toBe(c.binding.state.pendingId);
|
||||
});
|
||||
|
||||
test('AH continuation crop rejects prose, unsupported gutters and malformed numbered context', () => {
|
||||
const c = cases.find(item => item.p.pid === capturedAh.pid)!;
|
||||
for (const [name, screen] of [
|
||||
['four-space prose', c.screen.replace(/^.*\n/, ' Apply this edit now\n')],
|
||||
['four-space quoted prose', c.screen.replace(/^.*\n/, ' > Example\n')],
|
||||
['three-space gutter', c.screen.slice(1)],
|
||||
['six-space gutter', ` ${c.screen}`],
|
||||
['no numbered rows', c.screen.replace(/^\s*\d+\s+(?=[+\- ])/gm, ' +')],
|
||||
['one numbered row', c.screen.replace(/^(\s*\d+\s+)(?=[+\- ])/gm,
|
||||
(prefix, _group, offset) => offset === c.screen.indexOf(' 79 ') ? prefix : ' +')],
|
||||
]) {
|
||||
expect(screen, name).not.toBe(c.screen);
|
||||
expect(adEpoch(c, screen), name).toBeNull();
|
||||
expect(createPlanCountPermissionGuard()(screen, '', adEpoch(c, screen)), name).not.toBe('grant');
|
||||
}
|
||||
expect(selectTests(['test/fixtures/plan-count-permission-ah.json'], E2E_TOUCHFILES).selected.sort()).toEqual(
|
||||
selectTests(['test/fixtures/plan-count-permission-ae.json'], E2E_TOUCHFILES).selected.sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan-count-quoted-frame-ak', () => {
|
||||
const fs = fs_batching_permission_at;
|
||||
const os = os_batching_permission_at;
|
||||
const path = path_batching_permission_at;
|
||||
const exact = exact_plan_count_quoted_frame_ak;
|
||||
const native = capturedAc;
|
||||
const owned = owned_plan_count_quoted_frame_ak;
|
||||
const quote=(s:string)=>s.split('\n').map(row=>'> '+row).join('\n');
|
||||
|
||||
// A PTY transports bytes, not command-sized stdin events. Share the framing
|
||||
// code with the fake CLI so fragmented grants exercise the same receiver.
|
||||
function commandBuffer(){
|
||||
let pending='';
|
||||
return (chunk:string)=>{
|
||||
pending+=chunk;const commands:string[]=[];let end:number;
|
||||
while((end=pending.indexOf('\r'))!==-1){commands.push(pending.slice(0,end+1));pending=pending.slice(end+1);}
|
||||
return commands;
|
||||
};
|
||||
}
|
||||
test('fake CLI preserves command bytes across fragmented and coalesced PTY input',()=>{
|
||||
const expected=['/plan-ceo-review\r','1\r'];
|
||||
for(const chunks of [expected,['/plan-ceo-review\r','1','\r'],[...expected.join('')],[expected.join('')]]){
|
||||
const receive=commandBuffer();expect(chunks.flatMap(receive)).toEqual(expected);
|
||||
}
|
||||
const receive=commandBuffer();
|
||||
expect(receive('1')).toEqual([]);expect(receive('\r2\rtrailing')).toEqual(['1\r','2\r']);
|
||||
expect(receive('\r')).toEqual(['trailing\r']); // no unexpected bytes are discarded
|
||||
});
|
||||
|
||||
test('the exact wholly quoted AK pane is handled so the dispatcher sends no fallback',()=>{
|
||||
const screen=quote(exact.screen);
|
||||
expect(classifyPlanCountFrame(screen)).toBe('permission');
|
||||
expect(createPlanCountPermissionGuard()(screen,'',undefined)).toBe('handled');
|
||||
});
|
||||
test('quoted whole native frames remain inert across redraw, indentation, CRLF and terminal color',()=>{
|
||||
for(const source of [exact.screen,native.rows[0]!.screen,owned.screen])for(const transform of [
|
||||
(s:string)=>quote(s),(s:string)=>'\n'+quote(s)+'\n',(s:string)=>quote(s).replace(/^>/gm,' >'),
|
||||
(s:string)=>quote(s).replaceAll('\n','\r\n'),(s:string)=>'\x1b[31m'+quote(s)+'\x1b[0m',
|
||||
]){const s=transform(source),g=createPlanCountPermissionGuard();
|
||||
const expected=classifyPlanCountFrame(s)==='permission'?'handled':null;
|
||||
expect(g(s,'')).toBe(expected);expect(g(s,'⎿ Wrote 4 lines\n')).toBe(expected);}
|
||||
});
|
||||
test('quoted history cannot consume or authorize the current native grant',()=>{
|
||||
const s=native.rows[0]!.screen,g=createPlanCountPermissionGuard(),q=quote(s);
|
||||
expect(g(q,'')).toBe('handled');expect(g(s,q)).toBe('grant');expect(g(q,s)).toBe('handled');expect(g(s,q)).toBe('handled');
|
||||
expect(createPlanCountPermissionGuard()(s,'',null)).toBe('handled');
|
||||
expect(createPlanCountPermissionGuard()(s,'',{pendingId:'main:first',completedId:null})).toBe('grant');
|
||||
expect(createPlanCountPermissionGuard()(q,'',{pendingId:'main:first',completedId:null})).toBe('handled');
|
||||
});
|
||||
test('unquoted current native frames retain their policy even with quote characters in diff text or history',()=>{
|
||||
for(const s of native.rows.map(row=>row.screen))expect(createPlanCountPermissionGuard()(s,quote(s))).toBe('grant');
|
||||
expect(createPlanCountPermissionGuard()('> An old note\n'+native.rows[0]!.screen,'')).toBe('grant');
|
||||
expect(createPlanCountPermissionGuard()('No current pane',quote(exact.screen))).toBeNull();
|
||||
expect(createPlanCountPermissionGuard()('> Ordinary quoted prose, without a permission menu')).toBeNull();
|
||||
});
|
||||
test.skipIf(process.platform==='win32')('real dispatcher ignores quoted pane then grants the fresh owned native request once',async()=>{
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'count-quoted-frame-')),fake=path.join(dir,'fake-claude'),worker=path.join(dir,'worker.ts'),events=path.join(dir,'events.jsonl'),output=path.join(dir,'result.json'),report=path.join(dir,'report.md');
|
||||
fs.writeFileSync(report,'original');
|
||||
fs.writeFileSync(fake,`#!${process.execPath}\nconst receive=(${commandBuffer.toString()})();\n`+String.raw`
|
||||
import fs from 'node:fs';import path from 'node:path';
|
||||
const item=JSON.parse(process.env.QUOTED_FRAME_CASE),sid='quoted-frame-main',log=e=>fs.appendFileSync(item.events,JSON.stringify(e)+'\n');
|
||||
const transcript=path.join(process.env.CLAUDE_CONFIG_DIR,'projects','owned',sid+'.jsonl');fs.mkdirSync(path.dirname(transcript),{recursive:true});
|
||||
const native=(role,content,extra={})=>fs.appendFileSync(transcript,JSON.stringify({cwd:process.cwd(),sessionId:sid,isSidechain:false,timestamp:new Date().toISOString(),message:{role,content},...extra})+'\n');
|
||||
native('assistant',[{type:'text',text:'Reviewing the owned fixture.'}]);log({type:'start',pid:process.pid,cwd:process.cwd()});
|
||||
const settings=JSON.parse(process.argv[process.argv.indexOf('--settings')+1]);
|
||||
const hook=async name=>{for(const entry of settings.hooks[name]??[]){if(entry.matcher!=='^(Write|Edit)$')continue;
|
||||
const event={hook_event_name:name,tool_name:'Edit',session_id:sid,tool_use_id:'current',cwd:process.cwd(),transcript_path:transcript,tool_input:{file_path:item.report}};
|
||||
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 failed');}};
|
||||
const pane=item.screen.replaceAll('PLAN.md',item.report),paint=s=>process.stdout.write('\x1b[2J\x1b[H'+s.replaceAll('\n','\r\n'));
|
||||
let stage='startup';process.stdin.setRawMode?.(true);const dispatch=async input=>{
|
||||
log({type:'input',stage,input});
|
||||
if(stage==='startup'){stage='quoted';paint(item.quotedScreen);setTimeout(async()=>{await hook('PreToolUse');stage='current';paint(pane);},4200);return;}
|
||||
if(stage!=='current'){log({type:'unexpected'});return;}
|
||||
if(input!=='1\r')throw Error('One-time grant changed');stage='done';await hook('PostToolUse');
|
||||
const q={header:'Finding',question:'Apply the reviewed fix?',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'}}});paint('Done.\n');
|
||||
};process.stdin.on('data',async data=>{const chunk=data.toString();log({type:'chunk',stage,input:chunk});for(const input of receive(chunk))await dispatch(input);});process.on('SIGINT',()=>process.exit(0));process.stdin.resume();
|
||||
process.stdout.write('PTY_READY:'+item.events+'\x1b[2J\x1b[H');
|
||||
`);fs.chmodSync(fake,0o755);
|
||||
// Keep every physical terminal row inside the quote; adding a prefix to an
|
||||
// already120-column capture would otherwise wrap an unquoted continuation.
|
||||
const quotedScreen=exact.screen.split('\n').flatMap(row=>row.trimEnd().match(/.{1,116}/gu)??['']).map(row=>'> '+row).join('\n');
|
||||
const args={skillName:'plan-ceo-review',slashCommand:'/plan-ceo-review',followUpPrompt:'Review the disposable fixture.',expectedPlanPath:report,reviewCountCeiling:1,timeoutMs:25000,startupReadyMarker:'PTY_READY:'+events,env:{QUOTED_FRAME_CASE:JSON.stringify({events,report,screen:owned.screen,quotedScreen})}};
|
||||
fs.writeFileSync(worker,`import {runPlanSkillCounting} from ${JSON.stringify(pathToFileURL(path.join(import.meta.dir,'helpers/claude-pty-runner.ts')).href)};const result=await runPlanSkillCounting({...${JSON.stringify(args)},isLastStep0AUQ:()=>false,isReviewAUQ:()=>true});await Bun.write(${JSON.stringify(output)},JSON.stringify(result));`);
|
||||
const child=Bun.spawn([process.execPath,worker],{env:{...process.env,BROWSE_TERMINAL_BINARY:fake,EVALS_HERMETIC:'1'},stdout:'pipe',stderr:'pipe'}),timer=setTimeout(()=>child.kill('SIGKILL'),30000);
|
||||
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 result=JSON.parse(fs.readFileSync(output,'utf8')),rows=fs.readFileSync(events,'utf8').trim().split('\n').map(s=>JSON.parse(s));
|
||||
expect(result.outcome,JSON.stringify({result,rows})).toBe('ceiling_reached');expect(result.reviewCount).toBe(1);
|
||||
const chunks=rows.filter(r=>r.type==='chunk');
|
||||
expect(chunks.every(r=>['startup','current'].includes(r.stage))).toBe(true);
|
||||
expect(chunks.map(r=>r.input).join('')).toBe('/plan-ceo-review\r1\r');
|
||||
expect(rows.filter(r=>r.type==='input').map(r=>[r.stage,r.input])).toEqual([['startup','/plan-ceo-review\r'],['current','1\r']]);expect(rows.some(r=>r.type==='unexpected')).toBe(false);
|
||||
expect(()=>process.kill(rows[0].pid,0)).toThrow();expect(fs.existsSync(rows[0].cwd)).toBe(false);
|
||||
}finally{clearTimeout(timer);child.kill('SIGKILL');await child.exited;
|
||||
if(fs.existsSync(events)){const first=JSON.parse(fs.readFileSync(events,'utf8').split('\n')[0]!);try{if(fs.readFileSync('/proc/'+first.pid+'/cmdline','utf8').split('\0').includes(fake))process.kill(first.pid,'SIGKILL');}catch{}}
|
||||
fs.rmSync(dir,{recursive:true,force:true});}
|
||||
},32000);
|
||||
});
|
||||
Reference in new issue
Block a user