mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
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.
293 lines
22 KiB
TypeScript
293 lines
22 KiB
TypeScript
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 { spawnSync } from 'node:child_process';
|
|
import { createAutoplanArtifactRecorder, recordAutoplanArtifact, readPendingAutoplanArtifact,
|
|
autoplanArtifactRecorderStatus, autoplanArtifactApprovalBoundary } from './helpers/autoplan-artifact-recorder';
|
|
import type { NativePublicToolEvent } from './helpers/plan-count-transcript';
|
|
// Synthetic hook envelopes and owned temp paths. The live pending hook envelope
|
|
// was unpublished; these controls do not reconstruct it or provide paid coverage.
|
|
function fixture(approveEdits=false) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "artifact-' quote $()-"));
|
|
const cwd = path.join(root, 'repo with spaces'), config = path.join(root, 'config');
|
|
const stateRoot = path.join(root, 'home', '.gstack');
|
|
const project = path.join(config, 'projects', 'fixture');
|
|
const plans = path.join(stateRoot, 'projects', path.basename(cwd), 'ceo-plans');
|
|
fs.mkdirSync(cwd, {recursive:true}); fs.mkdirSync(project, {recursive:true}); fs.mkdirSync(plans, {recursive:true});
|
|
const session = 'synthetic-parent-session', transcript = path.join(project, session+'.jsonl');
|
|
fs.writeFileSync(transcript, '');
|
|
const artifact = path.join(plans, '2026-09-09-reviewed-plan.md');
|
|
fs.writeFileSync(artifact, 'Prior retained behavior.\n');
|
|
const startedAt = Date.now()-10;
|
|
const recorder = createAutoplanArtifactRecorder(cwd,config,stateRoot,approveEdits);
|
|
const event = (kind='PreToolUse',toolUseId='toolu_pending') => ({hook_event_name:kind,tool_name:'Edit',
|
|
session_id:session,tool_use_id:toolUseId,cwd,transcript_path:transcript,
|
|
tool_input:{file_path:artifact,old_string:'BODY_OLD_SENTINEL',new_string:'BODY_NEW_SENTINEL'}});
|
|
const write = (input:unknown) => recordAutoplanArtifact(typeof input==='string' ? input : JSON.stringify(input),recorder.file,cwd,config,stateRoot);
|
|
const history:NativePublicToolEvent[] = [
|
|
{kind:'use',sessionId:session,toolUseId:'toolu_prior',timestamp:new Date(startedAt).toISOString(),name:'Write',input:{file_path:artifact}},
|
|
{kind:'result',sessionId:session,toolUseId:'toolu_prior',timestamp:new Date(startedAt+1).toISOString(),isError:false},
|
|
];
|
|
const read = (tools=history,start=startedAt,now=Date.now()) => readPendingAutoplanArtifact(recorder.file,cwd,config,stateRoot,start,tools,now);
|
|
const status = () => autoplanArtifactRecorderStatus(recorder.file,cwd,config,stateRoot);
|
|
return {root,cwd,config,stateRoot,project,session,transcript,artifact,startedAt,recorder,event,write,history,read,status,
|
|
dispose:()=>{recorder.dispose();fs.rmSync(root,{recursive:true,force:true})}};
|
|
}
|
|
|
|
describe('owned Autoplan pending artifact metadata recorder',()=>{
|
|
test('pending authority is metadata only, with no body, result, success or phase evidence',()=>{
|
|
const f=fixture();try{
|
|
expect(f.status()).toEqual({status:'idle'});expect(f.read()).toBeUndefined();
|
|
const e=f.event() as ReturnType<typeof f.event> & {tool_response?:unknown};e.tool_response={content:'RESULT_SENTINEL'};f.write(e);
|
|
expect(f.read()).toEqual({source:'pre_tool_use',sessionId:f.session,toolUseId:'toolu_pending',tool:'Edit',file:f.artifact,timestamp:expect.any(String)});
|
|
const raw=fs.readFileSync(f.recorder.file,'utf8');
|
|
for(const secret of ['BODY_OLD_SENTINEL','BODY_NEW_SENTINEL','RESULT_SENTINEL','old_string','new_string','tool_response'])expect(raw).not.toContain(secret);
|
|
expect(Object.keys(JSON.parse(raw).pending).sort()).toEqual(['file','sessionId','source','timestamp','tool','toolUseId','transcriptPath'].sort());
|
|
expect(fs.statSync(f.recorder.file).mode&0o777).toBe(0o600);
|
|
}finally{f.dispose()}
|
|
});
|
|
test.each(['PostToolUse','PostToolUseFailure'])('%s closes and tombstones without creating successful native history',kind=>{
|
|
const f=fixture();try{
|
|
f.write(f.event());expect(f.read()).toBeDefined();f.write(f.event(kind));expect(f.status()).toEqual({status:'idle'});expect(f.read()).toBeUndefined();
|
|
f.write(f.event());expect(f.read()).toBeUndefined();f.write(f.event('PreToolUse','toolu_next'));expect(f.read()?.toolUseId).toBe('toolu_next');
|
|
expect(f.history).toHaveLength(2);
|
|
}finally{f.dispose()}
|
|
});
|
|
test('same pending replay never refreshes time; earlier completion cannot reopen',()=>{
|
|
const f=fixture();try{
|
|
f.write(f.event('PostToolUse','toolu_older'));
|
|
f.write(f.event());const before=fs.readFileSync(f.recorder.file,'utf8');f.write(f.event());expect(fs.readFileSync(f.recorder.file,'utf8')).toBe(before);
|
|
f.write(f.event('PostToolUse','toolu_older'));expect(f.read()?.toolUseId).toBe('toolu_pending');
|
|
f.write(f.event('PreToolUse','toolu_older'));expect(f.read()?.toolUseId).toBe('toolu_pending');
|
|
f.write(f.event('PostToolUse'));f.write(f.event('PreToolUse','toolu_older'));expect(f.read()).toBeUndefined();
|
|
}finally{f.dispose()}
|
|
});
|
|
test.each(['cwd','session','subagent'])('foreign %s cannot clear, replace or poison an owned parent',kind=>{
|
|
const f=fixture();try{
|
|
f.write(f.event());const before=fs.readFileSync(f.recorder.file,'utf8');
|
|
for(const hook of ['PreToolUse','PostToolUse','PostToolUseFailure']){
|
|
const e:Record<string,unknown>=f.event(hook,'toolu_foreign');
|
|
if(kind==='cwd')e.cwd=path.join(f.root,'foreign');
|
|
if(kind==='session'){e.session_id='foreign-session';e.transcript_path=path.join(f.project,'foreign-session.jsonl');fs.writeFileSync(e.transcript_path as string,'')}
|
|
if(kind==='subagent')e.agent_id='child-agent';
|
|
f.write(e);expect(f.status().status).toBe('pending');expect(fs.readFileSync(f.recorder.file,'utf8')).toBe(before);
|
|
}
|
|
}finally{f.dispose()}
|
|
});
|
|
test('only allowlisted Edit can become pending; foreign concurrent mutation invalidates ambiguity',()=>{
|
|
for(const kind of ['Write','unowned','different-id','different-path']){
|
|
const f=fixture();try{
|
|
const e=f.event('PreToolUse',kind==='different-id'?'toolu_other':'toolu_pending');
|
|
if(kind==='Write')e.tool_name='Write';
|
|
if(kind==='unowned')e.tool_input.file_path=path.join(f.stateRoot,'config');
|
|
if(kind==='different-path'){e.tool_input.file_path=path.join(path.dirname(f.artifact),'2026-09-09-other.md');fs.writeFileSync(e.tool_input.file_path,'old')}
|
|
if(kind==='Write'||kind==='unowned'){f.write(e);expect(f.status().status).toBe('idle')}
|
|
f.write(f.event());f.write(e);expect(f.status().status).toBe('invalid');expect(f.read()).toBeUndefined();
|
|
f.write(f.event('PostToolUse'));f.write(f.event('PreToolUse','toolu_later'));expect(f.status().status).toBe('invalid');
|
|
}finally{f.dispose()}
|
|
}
|
|
});
|
|
test.each(['same-file','foreign-path'])('unseen mutation completion on %s invalidates a pending request',target=>{
|
|
const f=fixture();try{
|
|
f.write(f.event());const e=f.event('PostToolUse','toolu_unseen');
|
|
if(target==='foreign-path')e.tool_input.file_path=path.join(f.stateRoot,'config');
|
|
f.write(e);expect(f.status().status).toBe('invalid');expect(f.read()).toBeUndefined();
|
|
}finally{f.dispose()}
|
|
});
|
|
test('malformed owned requests fail closed without persisting their contents',()=>{
|
|
for(const kind of ['bad-json','missing-cwd','outside-transcript','wrong-tool','empty-old','same-body','replace-all','oversized']){
|
|
const f=fixture();try{
|
|
const e:Record<string,any>=f.event();
|
|
if(kind==='missing-cwd')delete e.cwd;
|
|
if(kind==='outside-transcript'){e.transcript_path=path.join(f.root,f.session+'.jsonl');fs.writeFileSync(e.transcript_path,'')}
|
|
if(kind==='wrong-tool')e.tool_name='Read';
|
|
if(kind==='empty-old')e.tool_input.old_string='';
|
|
if(kind==='same-body')e.tool_input.new_string=e.tool_input.old_string;
|
|
if(kind==='replace-all')e.tool_input.replace_all=true;
|
|
if(kind==='oversized')e.tool_input.new_string='x'.repeat(4*1024*1024);
|
|
f.write(kind==='bad-json'?'{not-json':e);expect(f.status().status).toBe('invalid');expect(f.read()).toBeUndefined();
|
|
expect(fs.readFileSync(f.recorder.file,'utf8')).not.toContain('BODY_NEW_SENTINEL');
|
|
}finally{f.dispose()}
|
|
}
|
|
});
|
|
test('read binds actual session, interval and published identity precedence',()=>{
|
|
const f=fixture();try{
|
|
f.write(f.event());expect(f.read()).toBeDefined();
|
|
expect(f.read([],f.startedAt)).toBeUndefined();
|
|
expect(f.read(f.history.map(e=>({...e,sessionId:'other'})))).toBeUndefined();
|
|
expect(f.read([...f.history,{...f.history[0]!,sessionId:'other'}])).toBeUndefined();
|
|
expect(f.read(f.history,Date.now()+1000)).toBeUndefined();expect(f.read(f.history,f.startedAt,f.startedAt)).toBeUndefined();
|
|
for(const kind of ['use','result'] as const)expect(f.read([...f.history,{kind,sessionId:f.session,toolUseId:'toolu_pending',timestamp:new Date().toISOString()}])).toBeUndefined();
|
|
expect(readPendingAutoplanArtifact(undefined,f.cwd,f.config,f.stateRoot,f.startedAt,f.history)).toBeUndefined();
|
|
expect(readPendingAutoplanArtifact(f.recorder.file,f.cwd,null,f.stateRoot,f.startedAt,f.history)).toBeUndefined();
|
|
expect(readPendingAutoplanArtifact(f.recorder.file,f.cwd,f.config,path.join(f.root,'other'),f.startedAt,f.history)).toBeUndefined();
|
|
}finally{f.dispose()}
|
|
});
|
|
test.each([NaN, Infinity])('a non-finite reader clock %s cannot supply pending authority',now=>{
|
|
const f=fixture();try{f.write(f.event());expect(f.read(f.history,f.startedAt,now)).toBeUndefined()}finally{f.dispose()}
|
|
});
|
|
test('busy, oversized, symlinked and exhausted state cannot supply authority',()=>{
|
|
for(const kind of ['busy','oversized','symlinked','exhausted']){
|
|
const f=fixture();try{
|
|
f.write(f.event());
|
|
if(kind==='busy')fs.writeFileSync(f.recorder.file+'.lock','');
|
|
if(kind==='oversized')fs.writeFileSync(f.recorder.file,' '.repeat(64*1024+1));
|
|
if(kind==='symlinked'){const backup=f.recorder.file+'.backup';fs.renameSync(f.recorder.file,backup);fs.symlinkSync(backup,f.recorder.file)}
|
|
if(kind==='exhausted'){f.write(f.event('PostToolUse'));const s=JSON.parse(fs.readFileSync(f.recorder.file,'utf8'));s.seenIds=Array.from({length:128},(_,i)=>'toolu_'+i);fs.writeFileSync(f.recorder.file,JSON.stringify(s));f.write(f.event('PreToolUse','toolu_overflow'))}
|
|
expect(f.read()).toBeUndefined();expect(f.status().status).toBe(kind==='busy'?'busy':'invalid');
|
|
}finally{f.dispose()}
|
|
}
|
|
});
|
|
test('all generated hook commands are bounded, correctly quoted and silent',()=>{
|
|
const f=fixture();try{
|
|
expect(Object.keys(f.recorder.hooks).sort()).toEqual(['PostToolUse','PostToolUseFailure','PreToolUse']);
|
|
for(const kind of ['PreToolUse','PostToolUse','PostToolUseFailure'] as const){
|
|
const entries=f.recorder.hooks[kind];expect(entries).toHaveLength(1);expect(entries[0]!.matcher).toBe('^(Write|Edit)$');
|
|
const hook=entries[0]!.hooks[0]!;expect(hook.timeout).toBe(5);
|
|
for(const input of [JSON.stringify(f.event(kind)),'{broken']){
|
|
const child=spawnSync('bash',['-c',hook.command],{cwd:f.cwd,input,encoding:'utf8',timeout:6000});
|
|
expect(child.error).toBeUndefined();expect(child.status).toBe(0);expect(child.stdout).toBe('');expect(child.stderr).toBe('');
|
|
}
|
|
}
|
|
}finally{f.dispose()}
|
|
});
|
|
test('a hook without input EOF exits silently within its internal timeout',async()=>{
|
|
const f=fixture(),hook=f.recorder.hooks.PreToolUse[0]!.hooks[0]!;
|
|
const child=Bun.spawn(['bash','-c','exec '+hook.command],{cwd:f.cwd,stdin:'pipe',stdout:'pipe',stderr:'pipe'});
|
|
let forced=false;const timer=setTimeout(()=>{forced=true;child.kill('SIGKILL')},6000);
|
|
try{
|
|
const [code,out,err]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);
|
|
expect(forced).toBe(false);expect(code).toBe(0);expect(out).toBe('');expect(err).toBe('');
|
|
expect(f.status()).toEqual({status:'invalid',reason:'stdin_timeout'});
|
|
}finally{clearTimeout(timer);child.stdin.end();if(child.exitCode===null){child.kill('SIGKILL');await child.exited}f.dispose()}
|
|
},7000);
|
|
});
|
|
|
|
// Real hook subprocesses and the public JSONL reader; no synthetic phase success
|
|
// and no reconstruction of the unpublished paid request.
|
|
function approvalFixture(approve=true, activate=true) {
|
|
const f=fixture(approve), startedAt=Date.now();
|
|
if (approve && activate) f.recorder.startEditApproval!(startedAt);
|
|
const event=f.event();event.tool_input.old_string='Prior retained behavior.';event.tool_input.new_string='Reviewed retained behavior.';
|
|
const records:any[]=[
|
|
{message:{role:'assistant',id:'msg_prior',content:[{type:'tool_use',id:'toolu_prior',name:'Write',input:{file_path:f.artifact}}]}},
|
|
{message:{role:'user',content:[{type:'tool_result',tool_use_id:'toolu_prior',is_error:false,content:'Written'}]}},
|
|
].map(record=>({cwd:f.cwd,sessionId:f.session,isSidechain:false,timestamp:new Date(startedAt).toISOString(),...record}));
|
|
const publish=()=>fs.writeFileSync(f.transcript,records.map(record=>JSON.stringify(record)+'\n').join(''));
|
|
const current=()=>({cwd:f.cwd,sessionId:f.session,isSidechain:false,timestamp:new Date(startedAt).toISOString(),requestId:'req_current',
|
|
message:{role:'assistant',id:'msg_current',content:[{type:'tool_use',id:event.tool_use_id,name:'Edit',input:{...event.tool_input}}]}});
|
|
const invoke=(input:unknown=event)=>{
|
|
const child=spawnSync('bash',['-c',f.recorder.hooks.PreToolUse[0]!.hooks[0]!.command],
|
|
{cwd:f.cwd,input:JSON.stringify(input),encoding:'utf8',timeout:6000});
|
|
expect(child.error).toBeUndefined();expect(child.status).toBe(0);expect(child.stderr).toBe('');
|
|
return child.stdout ? JSON.parse(child.stdout) : undefined;
|
|
};
|
|
publish();return {...f,startedAt,event,records,publish,current,invoke};
|
|
}
|
|
|
|
describe('explicit native approval for owned Autoplan artifact Edits',()=>{
|
|
test.each(['unpublished','published','eng-artifact'])('%s approves once without changing bytes, JSONL or phase evidence',kind=>{
|
|
const f=approvalFixture();try{
|
|
if(kind==='eng-artifact'){
|
|
f.event.tool_input.file_path=path.join(path.dirname(path.dirname(f.artifact)),'branch-test-plan-20260911-055000.md');
|
|
fs.writeFileSync(f.event.tool_input.file_path,fs.readFileSync(f.artifact));
|
|
f.records[0].message.content[0].input.file_path=f.event.tool_input.file_path;
|
|
}
|
|
if(kind==='published')f.records.push(f.current());
|
|
f.publish();const journal=fs.readFileSync(f.transcript,'utf8'),before=fs.readFileSync(f.event.tool_input.file_path,'utf8');
|
|
expect(f.invoke()).toEqual({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow'}});
|
|
const state=fs.readFileSync(f.recorder.file,'utf8');
|
|
expect(JSON.parse(state).pending.editDigest).toBeDefined();expect(JSON.parse(state).seenIds).toEqual([f.event.tool_use_id]);
|
|
for(const body of [f.event.tool_input.old_string,f.event.tool_input.new_string,'tool_response'])expect(state).not.toContain(body);
|
|
expect(f.invoke()).toBeUndefined();expect(fs.readFileSync(f.recorder.file,'utf8')).toBe(state);
|
|
expect(f.invoke({...f.event,hook_event_name:'PostToolUse',tool_response:'Not native history'})).toBeUndefined();
|
|
expect(f.status().status).toBe('idle');expect(f.invoke()).toBeUndefined();
|
|
expect(fs.readFileSync(f.event.tool_input.file_path,'utf8')).toBe(before);expect(fs.readFileSync(f.transcript,'utf8')).toBe(journal);
|
|
}finally{f.dispose()}
|
|
});
|
|
test.each(['passive','not-started'])('%s remains silent even for a valid owned Edit',kind=>{
|
|
const f=approvalFixture(kind!=='passive',false);try{expect(f.invoke()).toBeUndefined()}finally{f.dispose()}
|
|
});
|
|
test.each(['no-history','old-history','failed-write','unresolved-write','concurrent-edit','foreign-mutation','duplicate-use',
|
|
'completed-current','conflicting-current','foreign-session','subagent-history','agent-id-history','other-journal-history','future-history','wrong-cwd','subagent',
|
|
'wrong-session','foreign-transcript','missing-old','duplicate-old','future-mtime','replace-all','Write','Bash',
|
|
'snapshot','config','native-plan','foreign-file','symlink','locked'])('%s cannot grant a native decision',kind=>{
|
|
const f=approvalFixture();try{
|
|
const e=f.event as Record<string,any>;
|
|
if(kind==='no-history')f.records.length=0;
|
|
if(kind==='old-history')f.records.forEach(r=>r.timestamp=new Date(f.startedAt-1).toISOString());
|
|
if(kind==='failed-write')f.records[1].message.content[0].is_error=true;
|
|
if(kind==='unresolved-write')f.records.pop();
|
|
if(kind==='concurrent-edit'||kind==='foreign-mutation'){
|
|
const other=f.current();other.message.content[0].id='toolu_conflict';
|
|
if(kind==='foreign-mutation')other.message.content[0].input.file_path=path.join(f.stateRoot,'config');
|
|
f.records.push(other);
|
|
}
|
|
if(kind==='duplicate-use')f.records.splice(1,0,f.records[0]);
|
|
if(kind==='completed-current')f.records.push(f.current(),{...f.records[1],message:{role:'user',content:[{type:'tool_result',tool_use_id:e.tool_use_id,is_error:false}]}});
|
|
if(kind==='conflicting-current'){const current=f.current();current.message.content[0].input.new_string='Different request';f.records.push(current)}
|
|
if(kind==='foreign-session')f.records.forEach(r=>r.sessionId='another-parent');
|
|
if(kind==='subagent-history')f.records.forEach(r=>r.isSidechain=true);
|
|
if(kind==='agent-id-history')f.records.forEach(r=>r.agentId='child-agent');
|
|
if(kind==='other-journal-history'){const other=path.join(f.config,'projects','other',f.session+'.jsonl');fs.mkdirSync(path.dirname(other));fs.writeFileSync(other,fs.readFileSync(f.transcript));f.records.length=0;}
|
|
if(kind==='future-history')f.records.forEach(r=>r.timestamp=new Date(Date.now()+10000).toISOString());
|
|
if(kind==='wrong-cwd')e.cwd=path.join(f.root,'other');
|
|
if(kind==='subagent')e.agent_id='child';
|
|
if(kind==='wrong-session'){e.session_id='another-parent';e.transcript_path=path.join(f.project,e.session_id+'.jsonl');fs.writeFileSync(e.transcript_path,'')}
|
|
if(kind==='foreign-transcript'){e.transcript_path=path.join(f.root,f.session+'.jsonl');fs.writeFileSync(e.transcript_path,'')}
|
|
if(kind==='missing-old')e.tool_input.old_string='Absent text';
|
|
if(kind==='duplicate-old')fs.writeFileSync(f.artifact,'Prior retained behavior. Prior retained behavior.');
|
|
if(kind==='future-mtime')fs.utimesSync(f.artifact,new Date(),new Date(Date.now()+10000));
|
|
if(kind==='replace-all')e.tool_input.replace_all=true;
|
|
if(kind==='Write'||kind==='Bash')e.tool_name=kind;
|
|
if(['snapshot','config','native-plan','foreign-file'].includes(kind)){
|
|
e.tool_input.file_path=kind==='snapshot'?path.join(path.dirname(f.artifact),'snapshot.md'):
|
|
kind==='config'?path.join(f.stateRoot,'config'):kind==='native-plan'?path.join(f.config,'plans','owned-plan.md'):path.join(f.root,'foreign.md');
|
|
fs.mkdirSync(path.dirname(e.tool_input.file_path),{recursive:true});fs.writeFileSync(e.tool_input.file_path,'Prior retained behavior.');
|
|
f.records[0].message.content[0].input.file_path=e.tool_input.file_path;
|
|
}
|
|
if(kind==='symlink'){fs.renameSync(f.artifact,f.artifact+'.original');fs.symlinkSync(f.artifact+'.original',f.artifact)}
|
|
if(kind==='locked')fs.writeFileSync(f.recorder.file+'.lock','');
|
|
f.publish();expect(f.invoke()).toBeUndefined();
|
|
}finally{f.dispose()}
|
|
});
|
|
test('rejected owned Edit stops before UI fallback; initial Write leaves unrelated navigation available',()=>{
|
|
const f=approvalFixture();try{
|
|
expect(f.invoke({...f.event,tool_name:'Write'})).toBeUndefined();
|
|
expect(autoplanArtifactApprovalBoundary(f.status())).toBe('clear');
|
|
f.records[1].message.content[0].is_error=true;f.publish();
|
|
expect(f.invoke()).toBeUndefined();
|
|
expect(f.status()).toEqual({status:'invalid',reason:'approval_withheld'});
|
|
expect(autoplanArtifactApprovalBoundary(f.status())).toBe('failed');
|
|
// The failed route is sticky across repeated hooks and cannot send UI input.
|
|
expect(f.invoke()).toBeUndefined();expect(autoplanArtifactApprovalBoundary(f.status())).toBe('failed');
|
|
const retained=JSON.parse(fs.readFileSync(f.recorder.file,'utf8'));
|
|
expect(retained.pending.toolUseId).toBe(f.event.tool_use_id);
|
|
}finally{f.dispose()}
|
|
const g=approvalFixture();try{
|
|
expect(g.invoke()?.hookSpecificOutput.permissionDecision).toBe('allow');
|
|
expect(autoplanArtifactApprovalBoundary(g.status())).toBe('pending');
|
|
expect(g.invoke({...g.event,hook_event_name:'PostToolUse'})).toBeUndefined();
|
|
expect(autoplanArtifactApprovalBoundary(g.status())).toBe('clear');
|
|
}finally{g.dispose()}
|
|
});
|
|
test('changed replay cannot refresh approval; completion hooks cannot fabricate required history',()=>{
|
|
const f=approvalFixture();try{
|
|
expect(f.invoke()?.hookSpecificOutput.permissionDecision).toBe('allow');
|
|
fs.writeFileSync(f.artifact,'Changed bytes.');expect(f.invoke()).toBeUndefined();expect(f.status().status).toBe('invalid');
|
|
}finally{f.dispose()}
|
|
const g=approvalFixture();try{
|
|
g.records.length=0;g.publish();expect(g.invoke()).toBeUndefined();
|
|
expect(g.invoke({...g.event,hook_event_name:'PostToolUse',tool_response:{success:true}})).toBeUndefined();
|
|
g.event.tool_use_id='toolu_next';expect(g.invoke()).toBeUndefined();
|
|
}finally{g.dispose()}
|
|
});
|
|
test.each(['repeat','future','before-recorder'])('invalid %s activation fails closed',kind=>{
|
|
const f=approvalFixture(true,kind==='repeat');try{
|
|
const started=kind==='future'?Date.now()+10000:kind==='before-recorder'?f.startedAt-10000:f.startedAt;
|
|
expect(()=>f.recorder.startEditApproval!(started)).toThrow();expect(f.invoke()).toBeUndefined();
|
|
}finally{f.dispose()}
|
|
});
|
|
});
|