Files
gstack/test/plan-create-combined-permission.test.ts
T
garrytan 74c3184dcc test(pty): grant an owned Create pane whose title row is cropped
The targeted batching rerun on Claude Code 2.1.284 left its first report
Write unanswered for 1,372 s and timed out: the viewport began at the
pane's relative file row and rule, with the 'Create file' title cropped
above, so the preview parser rejected the file row as foreign. That row
must now resolve to the owned path and is skipped before the unchanged
line-by-line preview match. Replay controls reject another file, another
directory and an edited preview row.
2026-09-29 19:47:15 +00:00

152 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {describe, expect, test} from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {createFilePermissionRecorder, recordFilePermission, currentFilePermissionEpoch} from './helpers/plan-count-file-permission';
import {readPlanCountTranscript} from './helpers/plan-count-transcript';
import {createPlanCountPermissionGuard} from './helpers/claude-pty-runner';
import captures from './fixtures/plan-create-combined-permission-70b.json';
import croppedTitle from './fixtures/plan-create-cropped-title-batching.json';
function fixture(captured: typeof captures[number]) {
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'create-combined-'));
const cwd=path.join(dir,'owned report directory'), config=path.join(dir,'config');fs.mkdirSync(cwd);
const expected=path.join(cwd,path.basename(captured.event.input.file_path));
const sessionId=captured.event.sessionId, id=captured.event.toolUseId;
const journal=path.join(config,'projects','owned',sessionId+'.jsonl');fs.mkdirSync(path.dirname(journal),{recursive:true});
const recorder=createFilePermissionRecorder(cwd,config,expected)!;
const startedAt=Date.now()-1000, timestamp=new Date().toISOString();
// Only fixture ownership/time are rebound. Source content and viewport remain
// literal; replace the original directory in the native footer, not its preview.
const promptAt=captured.screen.indexOf('Do you want to create ');
const screen=captured.screen.slice(0,promptAt)+captured.screen.slice(promptAt)
.replaceAll(path.dirname(captured.event.input.file_path),cwd);
const input={...captured.event.input,file_path:expected};
const block={type:'tool_use',id,name:'Write',input};
const native=(role:string,content:any[])=>({cwd,sessionId,isSidechain:false,timestamp,message:{role,content}});
const original=[native('assistant',[{type:'text',text:'Preparing the review.'},block])];
const write=(rows:any[])=>fs.writeFileSync(journal,rows.map(r=>JSON.stringify(r)).join('\n')+'\n');
write(original);
recordFilePermission(JSON.stringify({hook_event_name:'PreToolUse',tool_name:'Write',session_id:sessionId,
tool_use_id:id,cwd,transcript_path:journal,tool_input:input}),recorder.file,cwd,config,expected);
const read=(s=screen)=>currentFilePermissionEpoch(recorder.file,expected,cwd,config,startedAt,
readPlanCountTranscript(config,cwd),s);
return {dir,cwd,screen,config,expected,sessionId,id,journal,recorder,input,block,native,original,write,read,
close(){recorder.dispose();fs.rmSync(dir,{recursive:true,force:true});}};
}
for(const captured of captures) {
test(`${captured.case}: actual combined Create grants the owned pending Write once`,()=>{
const f=fixture(captured);try {
const epoch=f.read();expect(epoch?.pendingId).toBe(`${f.sessionId}:${f.id}`);
const guard=createPlanCountPermissionGuard();
expect(guard(f.screen,'',epoch)).toBe('grant');
expect(guard(f.screen,'',epoch)).toBe('handled');
expect(fs.readFileSync(f.recorder.file,'utf8')).not.toContain(f.input.content);
}finally{f.close();}
});
for(const variant of ['single-line','different-wrap','no-key-hint','standalone'])
test(`${captured.case}: native option layout ${variant} preserves one-time action`,()=>{
const f=fixture(captured);try {
const at=f.screen.indexOf(' 2.');const prefix=f.screen.slice(0,at);
let footer=f.screen.slice(at);
if(variant==='single-line')footer=footer.replace(/;\s+Yes,\s+and\s+always\s+allow\s+access\s+to\s+/,'; Yes, and always allow access to ');
if(variant==='different-wrap')footer=footer.replace(/;\s+Yes,\s+and\s+always\s+allow\s+access\s+to\s+/,';\n Yes, and always\n allow access to ');
if(variant==='no-key-hint')footer=footer.replace(/\s*\(shift\+tab\)/,'');
if(variant==='standalone')footer=footer.replace(/;\s+Yes,\s+and\s+always\s+allow\s+access\s+to\s+[^\r\n]+?\s+for\s+this\s+session/,'');
expect(f.read(prefix+footer)?.pendingId).toBe(`${f.sessionId}:${f.id}`);
}finally{f.close();}
});
for(const variant of ['foreign-directory','ancestor','relative','traversal','quoted-directory','extra-directory',
'selected-2','selected-no','missing-no','changed-action','permanent-access','quoted','auq','missing-footer'])
test(`${captured.case}: combined Create rejects ${variant}`,()=>{
const f=fixture(captured);try {
let screen=f.screen;
if(variant==='foreign-directory')screen=screen.replace(f.cwd,f.cwd+'-foreign');
if(variant==='ancestor')screen=screen.replace(f.cwd,path.dirname(f.cwd));
if(variant==='relative')screen=screen.replace(f.cwd,'./owned report directory');
if(variant==='traversal')screen=screen.replace(f.cwd,f.cwd+'/../owned report directory');
if(variant==='quoted-directory')screen=screen.replace(f.cwd,'"'+f.cwd+'"');
if(variant==='extra-directory')screen=screen.replace(f.cwd,f.cwd+' and /foreign');
if(variant==='selected-2')screen=screen.replace('❯ 1. Yes',' 1. Yes').replace(' 2. Yes',' ❯ 2. Yes');
if(variant==='selected-no')screen=screen.replace('❯ 1. Yes',' 1. Yes').replace(' 3. No',' ❯ 3. No');
if(variant==='missing-no')screen=screen.replace('3. No','');
if(variant==='changed-action')screen=screen.replace('1. Yes','1. Yes, always allow');
if(variant==='permanent-access')screen=screen.replace(/for\s+this\s+session(?=\s*\(shift\+tab\))/,'permanently');
if(variant==='quoted')screen=screen.split('\n').map(l=>'> '+l).join('\n');
if(variant==='auq')screen='☐ Finding\n'+screen;
if(variant==='missing-footer')screen=screen.replace('Esc to cancel · Tab to amend','');
expect(screen).not.toBe(f.screen);expect(f.read(screen)).toBeFalsy();
}finally{f.close();}
});
for(const variant of ['foreign-path','foreign-id','completed','ambiguous','wrong-tool','wrong-content',
'missing-journal','wrong-session','completed-replay','conflicting-duplicate'])
test(`${captured.case}: combined footer cannot override ${variant} native evidence`,()=>{
const f=fixture(captured);try {
const changed=structuredClone(f.block);
if(variant==='foreign-path')changed.input.file_path=path.join(f.dir,path.basename(f.expected));
if(variant==='foreign-id')changed.id='foreign';
if(variant==='wrong-tool')changed.name='Edit';
if(variant==='wrong-content')changed.input.content='unrelated content';
const rows=[f.native('assistant',[{type:'text',text:'Preparing the review.'},changed])];
if(variant==='completed')rows.push(f.native('user',[{type:'tool_result',tool_use_id:f.id,content:'done'}]));
if(variant==='ambiguous')rows.push(f.native('assistant',[{...f.block,id:'foreign'}]));
if(variant==='completed-replay')rows.push(f.native('user',[{type:'tool_result',tool_use_id:f.id,content:'done'}]),f.native('assistant',[structuredClone(f.block)]));
if(variant==='conflicting-duplicate')rows.unshift(f.native('assistant',[{...f.block,input:{...f.input,content:'different'}}]));
if(variant==='wrong-session')rows[0].sessionId='foreign';
f.write(rows);if(variant==='missing-journal')fs.unlinkSync(f.journal);
expect(f.read()).toBeNull();
}finally{f.close();}
});
}
describe('Create pane cropped below its title (plan-eng-multi-finding-batching, CLI 2.1.284)', () => {
function cropped() {
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'create-cropped-'));
const cwd=path.join(dir,path.basename(croppedTitle.cwd)), config=path.join(dir,'config');fs.mkdirSync(cwd);
const originalDir=path.dirname(croppedTitle.event.input.file_path);
const expected=path.join(dir,path.basename(originalDir),path.basename(croppedTitle.event.input.file_path));
fs.mkdirSync(path.dirname(expected));
const {sessionId,toolUseId:id}=croppedTitle.event, timestamp=new Date().toISOString();
const journal=path.join(config,'projects','owned',sessionId+'.jsonl');fs.mkdirSync(path.dirname(journal),{recursive:true});
const recorder=createFilePermissionRecorder(cwd,config,expected)!;
const input={...croppedTitle.event.input,file_path:expected};
fs.writeFileSync(journal,JSON.stringify({cwd,sessionId,isSidechain:false,timestamp,
message:{role:'assistant',content:[{type:'text',text:'Writing the review report.'},{type:'tool_use',id,name:'Write',input}]}})+'\n');
recordFilePermission(JSON.stringify({hook_event_name:'PreToolUse',tool_name:'Write',session_id:sessionId,
tool_use_id:id,cwd,transcript_path:journal,tool_input:input}),recorder.file,cwd,config,expected);
// The relative file row keeps its captured sibling layout; only the footer's
// absolute directory is rebound to this fixture.
const screen=croppedTitle.screen.replaceAll(originalDir,path.dirname(expected));
const read=(s=screen)=>currentFilePermissionEpoch(recorder.file,expected,cwd,config,Date.now()-1000,readPlanCountTranscript(config,cwd),s);
return {screen,read,id:`${sessionId}:${id}`,close(){recorder.dispose();fs.rmSync(dir,{recursive:true,force:true});}};
}
test('the captured pane shows its file row and rule but not the Create file title', () => {
expect(croppedTitle.screen).not.toMatch(/(?:^|\n) {0,3}Create file[ \t]*\n/);
expect(croppedTitle.screen.split('\n')[0]).toBe(' ../gstack-e2e-plan-eng-batching-DINQ9m/gstack-test-plan-eng-batching.md');
});
test('the owned file row binds the pending Write and grants it once', () => {
const f=cropped();try {
const epoch=f.read();expect(epoch?.pendingId).toBe(f.id);
const guard=createPlanCountPermissionGuard();
expect(guard(f.screen,'',epoch)).toBe('grant');
expect(guard(f.screen,'',epoch)).toBe('handled');
}finally{f.close();}
});
for(const [name,change] of [
['a file row naming another file',(s:string)=>s.replace('/gstack-test-plan-eng-batching.md\n','/other.md\n')],
['a file row in another directory',(s:string)=>s.replace(' ../gstack-e2e-plan-eng-batching-DINQ9m/',' ../elsewhere/')],
['an edited preview row',(s:string)=>s.replace('We will leave the','We will fix the')],
] as const) test(`the cropped pane rejects ${name}`,()=>{
const f=cropped();try {
const screen=change(f.screen);expect(screen).not.toBe(f.screen);expect(f.read(screen)).toBeFalsy();
}finally{f.close();}
});
});