mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
* test: delete test-infrastructure dead code (G) - exit-propagation drives the runner's real strict verdict (BunTestOutputClassifier + strictTestExitCode); delete the unused shardRunLooksTruncated predicate. - delete skill-coverage-matrix registry + its gate (nothing reads it; the floor already iterates skillCensus()). - delete touchfiles-facade export-parity tests (Bun fails missing imports at link time) and the duplicated E2E_TIERS tier-value test. - delete brain-cache-spec TRANSPORT_DEFAULT_POLICY, SKILL_RUN_RETENTION_DAYS and the now-unused BrainTrustPolicy type with their literal tests. AUTOPLAN_PREFLIGHT_BUDGET_BYTES stays: skill-preflight-budget enforces it against real resolver output. - delete audit-compliance's JSDoc-comment grep. * test: replace product tests that fake the product with real-boundary tests (F) - design: serve.test.ts drove an inline mirror server; now two tests run the real serve() on an ephemeral port (reload confinement, submit exit 0). - setup-gbrain: rollback + voyage tests execute the template-extracted init blocks (3 sites) instead of drifted local bash copies. - terminal-agent: internalHandler source greps replaced by a behavioral /internal/grant + /internal/revoke auth matrix (no/wrong/valid token). - /health: server-security-surface and the server-auth / security-audit-r2 / sidebar-tabs source greps fold into one liveness-only check on the real body; the L4 sidecar wiring gets a behavioral /pty-inject-scan test. - delete tautologies (browser-manager onDisconnect, memory-command #12), ios swiftui tap fixture self-check, memory-ingest put_page grep, detach source greps, sidebar-agent absence pins, dead-CSS pins + the dead CSS, security-audit-r2 Task 1 + the test-only meta-commands re-export, duplicate generated-SKILL.md checks. - make-pdf coverage-gaps cases move into their owner test files. * test: delete tests of dead eval code (A) - A1: the retired Eng lexical oracle (evaluateEngSeedCoverage, isEngSeedDecisionAUQ), the completion-handoff detector and the retained corpus had no paid caller since v1.87.6; delete their 26 replay files, ~2.6k helper LOC and fixtures, and the dead blocks in 8 mixed files (live hasNativePlanTerminal / batching assertions stay). - A2: dead viewport approvers in autoplan-artifact-permission and their 11 replay files + fixtures; recorder/launcher cases stay. - A3: never-wired oracles and seeders (autoplan-phase-order, eng-finding-fixture, ceo-paired-fixture, design-ui-scope, plan-skill-completion, pty-current-screen, required-reads, transcript-section-logger); plan-seed-submission now decodes through the production createPtyScreen; section manifests name their actual guard. - A4: zero-reference helper exports, plus execGit and invokeAndObserve found by the reachability pass. - 52 fixtures orphaned by the deletions; touchfile and selection-table entries for every deleted path. * test: clean up the paid eval lane (B1-B4, B6, B7) - B1: delete paid files that assert nothing or cannot pass meaningfully: skill-llm-eval-spec and skill-e2e-spec-execute (test.todo), gemini-e2e (+ gemini-session-runner; no gemini CLI in CI), ship-idempotency (red since v1.63), the two opus-4-7 *-sonnet overlay wrappers, conductor-prose (+ its source-evaluation replay), codex-e2e-plan-format; drop their keys, scripts and census rows. - B2: skill-llm-eval grades browse/sections/command-list.md with one union judge that also carries the baseline score pin; regression-vs-baseline deleted (paid run: pass, c4/c4/a4). - B3: memory-pipeline, ios-qa, ios-qa-swift-build and plan-tune-cathedral make no model calls; renamed out of the paid glob so they run on every PR. Swift builds need GSTACK_TEST_SWIFT=1; device stub deleted. - B4: codex-e2e*, outside-voice, aside and ios-device cannot run in the CI image; excluded from the weekly lane with a tracked re-entry condition. - B6: fold opus-47's negative routing controls into skill-routing-e2e journey-negatives (paid run: 3/3 unrouted) and delete the file. - B7: delete the never-green brain-privacy-gate eval; a free gstack-skill-start test now proves consent precedes artifacts egress. * test: retire the finding-count cluster and trim its helpers (C) - C0/C1: the five never-green evals (skill-e2e-autoplan-chain and skill-e2e-plan-{ceo,eng,design,devex}-finding-count) failed on harness and budget, never on skill behavior; delete them, their touchfile/tier ids, AUTOPLAN_CHAIN_BUDGET and the dedicated eighth periodic slice (--slices 7). - C2: delete the helper groups whose only paid consumers were those files (11 modules), trim claude-pty-runner and eng-seeded-coverage to the paid closure, and delete the free replay tests whose assertions exercised only that dead code (89 files, 135 orphaned fixtures). Blocks that used dead code only as input for a live subject keep their assertions: the multiSelect default moved to plan-review-decisions, runner PTY tests use inline caller policies, and the timer-safe budget checks moved to eng-finding-retry-budget. - The eight production-touching files stay except ceo-current-decision-record (its template read only feeds the retired counter). - CARVE_GUARDS.autoplan is behavioral 'none'; TODOS records the lost chain and per-finding cadence coverage with their re-entry tests. * 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. * test: start the plan-count history PTY on its readiness marker (H) The fake CLI prints a startup marker and the runner waits for it instead of the fixed 8 s startup sleep (8.6 s -> 0.9 s locally). eng-semantic-terminal's sleeping registration cases went with C; plan-count-timeout keeps the fixed wait because it asserts deadline behavior. * test: derive paid touchfiles from each eval's static closure (E) touchfiles.test.ts now checks, per key, that the paid file's static test/helpers and test/fixtures closure (plus fixture paths it names in string literals) is covered, and names the file, path, chain and key to fix when it is not. Free *.test.ts files are no longer touchfiles, so editing a free replay test stops selecting paid evals: 950 entries removed, 653 real closure paths added. The hand-copied inventories go: periodic-fixture-selection, fake-impeccable-touchfiles and 45 per-file selection examples. Selection for the sample edits (plan-eng-review template, claude-pty-runner, plan-count-fixture, gstack-config) loses no case under either profile. CONTRIBUTING documents the rule and its lower bound. * test: skip hollow tier shards and census judges in the paid planner (B5) A paid file is now skipped for a tier lane only when every E2E id it registers is known statically and none has that tier; ids come from the touchfile registrations and literal testName/*IfSelected arguments, so a comment or skill path that quotes another id cannot unschedule it, and computed names keep today's scheduling. --list and the manifest show each skip as "skipped: no E2E_TIERS id has tier <tier>". The weekly gate census drops the LLM judges (--skip-judges); they still run in the periodic census and PR gate lanes. Gate lane 52 -> 42 files, census 41; periodic 77 -> 69. * test: run seven paid evals on the current default capture model (B8) skill-e2e-{auq-matrix,plan-format,qa-bugs,retro,workflow} pinned claude-opus-4-7 and skill-e2e-office-hours plus -brain-writeback pinned claude-sonnet-4-6; none tests a historical model, so they now capture with resolveEvalModel('capture'), and the free harness tests that execute these registrations receive the same resolver. The paid re-pin run passed all of them. skill-e2e-{design,office-hours-phase4,plan-prosons,plan} keep claude-opus-4-7: six of their cases failed on the default model (three timeouts, a missing report file, a format miss and a posture score of 3), so per the plan's fallback they keep their pins with a TODOS entry. The pre-spend estimate and drop threshold are in docs/test-audit-2026-09.md. * test: guard the reduced suite against new test-of-test files - test/test-of-test-ratchet.test.ts records the 228 free tests that import only test/ code and fails on a new one, naming the owner test to extend instead; a stale baseline entry fails with the remove instruction. - test/helpers/resolve-repo-path.ts is the one specifier/literal resolver for the ratchet and the touchfile closure invariant, with its own unit tests. - CONTRIBUTING "Test tiers" describes the paid-failure workflow (fix, then one row in the detector's owner test) and the ratchet; TEST_PORTFOLIO gains the detector -> owner-test table and no longer claims an Autoplan chain eval. - TODOS: automatic exclusion policy for chronically red periodic files (P3), the deferred native-completion table collapse, the unused CEO payment seeder; the PTY readiness item is narrowed to the paid runner. - docs/test-audit-2026-09.md collects the triage, security mapping, inventories, selection proof, behavior-commit decisions and retained false positives. * v1.91.8.0 test: smaller suite, derived paid selection, retired never-green evals Release metadata for the test-reduction branch: VERSION 1.91.8.0 (1.91.7.0 is claimed by #2983), CHANGELOG with the measured before/after table and a contributor section, durations re-recorded on Ubicloud standard-16 (857 files, 0 failures), the agents digest, CONTRIBUTING's after-measurement row, the B8 fallback TODOS entry, and the after metrics, kept-vs-plan notes, B8 run and census estimate in docs/test-audit-2026-09.md. * fix(ubicloud): skip retrieval globs that match nothing instead of reporting a failed pull * test: count issue-numbered Design findings in the UI-scope gate eval
866 lines
65 KiB
TypeScript
866 lines
65 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';
|
||
import largeCeo from './fixtures/ceo-report-permission-fb10.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 (auto-approve file edits and common file commands) for this session (shift+tab)\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();}
|
||
});
|
||
});
|
||
|
||
test('large report crop verifies only a complete bounded source line and the current owned epoch',()=>{
|
||
const f=fixture();try{
|
||
const screen=largeCeo.viewport.replaceAll(path.dirname(largeCeo.expectedPath),path.dirname(f.expected))
|
||
.replaceAll(path.basename(largeCeo.expectedPath),path.basename(f.expected));
|
||
const prefix=Array.from({length:largeCeo.sourceLine-1},(_,i)=>`preceding line ${i+1}\n`).join('');
|
||
const complete=prefix+largeCeo.priorLine+'\n';
|
||
const report=complete+'tail\n'.repeat(Math.ceil((largeCeo.originalReportBytes-Buffer.byteLength(complete))/5));
|
||
expect(Buffer.byteLength(report)).toBeGreaterThan(64*1024);
|
||
fs.writeFileSync(f.expected,report);f.record('PreToolUse','first');
|
||
const check=()=>currentFilePermissionEpoch(f.recorder.file,f.expected,f.cwd,f.config,f.startedAt,f.transcript,screen);
|
||
expect(check()?.pendingId).toBe('main:first');
|
||
const guard=createPlanCountPermissionGuard();expect(guard(screen,'',check())).toBe('grant');
|
||
expect(guard(screen,'',check())).toBe('handled');
|
||
const valid=fs.readFileSync(f.recorder.file,'utf8');
|
||
for(const delta of [{cwd:'/foreign'},{expected:'/foreign/report.md'},{sessionId:'foreign'},{pendingId:null},{timestamp:new Date(f.startedAt-1).toISOString()}]){
|
||
fs.writeFileSync(f.recorder.file,JSON.stringify({...JSON.parse(valid),...delta}));expect(check()).toBeNull();
|
||
}
|
||
fs.writeFileSync(f.recorder.file,valid);
|
||
for(const content of [
|
||
report.replace(largeCeo.priorLine,'different prior line'),
|
||
'no requested line\n'.repeat(2),
|
||
prefix+'x'.repeat(64*1024)+largeCeo.priorLine+'\n',
|
||
prefix+'x'.repeat(64*1024-Buffer.byteLength(prefix)-Buffer.byteLength(largeCeo.priorLine))+largeCeo.priorLine+'\n',
|
||
]){fs.writeFileSync(f.expected,content);expect(check()).toBeNull();}
|
||
fs.writeFileSync(f.expected,report);
|
||
const target=f.expected+'.real';fs.renameSync(f.expected,target);fs.symlinkSync(target,f.expected);
|
||
expect(check()).toBeNull();
|
||
}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();
|
||
process.stdout.write('PTY_READY:'+item.events+'\x1b[2J\x1b[H');
|
||
`);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,startupReadyMarker:${JSON.stringify('PTY_READY:'+events)},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';
|
||
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';
|
||
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('AE crop admits one native divider only', () => {
|
||
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');
|
||
}
|
||
});
|
||
|
||
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);
|
||
});
|