// Isolated finite-budget regression: local fixtures only, no platform or model.
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
const ROOT=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..');
await fs.mkdir(path.join(ROOT,'.tmp'),{recursive:true});
const runRoot=await fs.mkdtemp(path.join(ROOT,'.tmp','batch-deadline-'));
process.env.ASSISTANT_LEARNING_HOME=path.join(runRoot,'learning');
process.env.ASSISTANT_BROWSER_CHANNEL ||= process.platform==='win32' ? 'msedge' : 'chromium';
process.env.ASSISTANT_BROWSER_HEADLESS='1';
const {dispatch,verify,withBatchDeadline,withVerifyDeadline}=await import('./browser-worker.mjs');
const results=[],tasks=new Set();
let serial=0,writeRequests=0,fontRequests=0;
const sockets=new Set();
const server=createServer((request,response)=>{
const url=new URL(request.url,'http://127.0.0.1');
if(url.pathname==='/pending-font'){fontRequests+=1;return;}
if(url.pathname==='/write'){writeRequests+=1;response.end('unexpected');return;}
if(url.pathname!=='/fixture'){response.writeHead(404).end();return;}
const mode=url.searchParams.get('mode') || 'normal';
response.setHeader('Content-Type','text/html; charset=utf-8');
response.setHeader('Set-Cookie','batch-fixture-session=retained; Path=/; Max-Age=3600; SameSite=Lax');
response.end(`
Local batch deadline fixtureAlready read local detail
${request.headers.cookie?.includes('batch-fixture-session=retained') ? 'retained' : 'fresh'}
`);
});
server.on('connection',socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
const base=`http://127.0.0.1:${server.address().port}/fixture`;
const read={kind:'read',selector:'#detail',output_name:'detail'};
const comments={kind:'extract',selector:'#comments-root',row_selector:'.comment-row',output_name:'comments',limit:10,complete_only:true,
fields:[{name:'item_id',source:'attribute',attribute:'data-item-id',required:true},{name:'author',selector:'.comment-author',source:'text',required:true},{name:'body',selector:'.comment-body',source:'text',required:true}],
batch:{scroll_selector:'#comments-window',pixels:80,max_batches:3,dedupe_field:'item_id'}};
const open=async mode=>{
const task=`batch-deadline-${++serial}`,url=base+'?mode='+mode;
tasks.add(task);
await dispatch('open',{task_id:task,account_key:'local-batch-deadline',entry_url:url});
await dispatch('ready',{task_id:task,identity_label:'Local fixture'});
return {task,url};
};
const close=async task=>{await dispatch('close',{task_id:task});tasks.delete(task);};
const run=async(mode,options={})=>{
const {task,url}=await open(mode),started=Date.now();
const result=await verify({task_id:task,entry_url:url,parameters:{},steps:[read,comments],expected_outputs:{detail:'Already read local detail',comments:{state:'nonempty'}}},{batchBudgetMs:4000,...options});
await fs.writeFile(path.join(runRoot,mode+'.json'),JSON.stringify(result,null,2));
return {task,result,elapsed_ms:Date.now()-started};
};
const assertEvidence=async result=>{
assert.equal(new Set(result.evidence.map(item=>item.capture_id)).size,result.evidence.length);
for(const capture of result.evidence){
const png=await fs.readFile(capture.screenshot_path);
assert.equal(png.subarray(1,4).toString(),'PNG');
}
assert.ok(result.step_timings.every(step=>Object.values(step.phase_ms).reduce((sum,value)=>sum+value,0)===step.duration_ms));
const saved=JSON.stringify(result);await new Promise(resolve=>setTimeout(resolve,180));
assert.equal(JSON.stringify(result),saved,'late browser/close callbacks must not mutate returned outputs or evidence');
};
const checkClosed=async({task,result,elapsed_ms},code)=>{
assert.equal(result.passed,false);assert.deepEqual([...result.error_codes].sort(),(Array.isArray(code) ? [...code] : [code]).sort(),JSON.stringify(result.errors));
assert.equal(result.session_closed,true);assert.equal((await dispatch('status',{task_id:task})).status,'closed');
assert.ok(elapsed_ms<8500,'internal deadline must cancel the ordinary 10s screenshot wait');
assert.ok(result.outputs.detail?.[0]?.text.includes('Already read local detail'));
assert.ok(result.step_timings.at(-1).phase_ms.batch_capture>0,'the slow operation must occur in actual batch capture');
await assertEvidence(result);await close(task);
};
try{
let cleaned=0;
assert.equal(await withBatchDeadline(async()=>17,{budgetMs:100,cleanupReserveMs:10,onTimeout:()=>{cleaned+=1;}}),17);
let release;
await assert.rejects(()=>withBatchDeadline(()=>new Promise((_,reject)=>{release=reject;}),{budgetMs:60,cleanupReserveMs:15,onTimeout:()=>{cleaned+=1;release(new Error('cancelled'));}}),error=>error.code==='BATCH_BUDGET');
assert.equal(cleaned,1);
let finished=false;
await assert.rejects(()=>withBatchDeadline(async()=>{const end=Date.now()+45;while(Date.now(){cleaned+=1;}}),error=>error.code==='BATCH_BUDGET');
assert.equal(finished,true);assert.equal(cleaned,2,'late resolution is rejected and cleanup occurs once');
results.push({name:'batch helper normal/cancel/late-resolution semantics'});
await assert.rejects(()=>withVerifyDeadline(()=>withBatchDeadline(()=>new Promise(()=>{}),{budgetMs:500,cleanupReserveMs:30}),{budgetMs:50,cleanupReserveMs:10,onTimeout:()=>{}}),error=>error.code==='VERIFY_BUDGET');
results.push({name:'outer VERIFY_BUDGET wins before a longer batch deadline'});
const normal=await run('normal');
assert.equal(normal.result.passed,true,JSON.stringify(normal.result.errors));assert.deepEqual(normal.result.outputs.comments.items.map(item=>item.item_id),['a','b','c','d']);
assert.equal(normal.result.outputs.comments.batch_summary.batches_read,3);await assertEvidence(normal.result);await close(normal.task);
results.push({name:'normal finite batches retain all real PNGs and unique rows',evidence:normal.result.evidence.length});
const first=await run('first-font');
await checkClosed(first,'BATCH_BUDGET');assert.equal(first.result.outputs.comments,undefined,'uncommitted first rows cannot become an empty/success output');
assert.ok(!first.result.evidence.some(item=>item.note==='有界读取第 1 批'),'unfinished screenshot must not fabricate a first batch');
results.push({name:'first real batch screenshot cutoff retains detail but no comment output',elapsed_ms:first.elapsed_ms,evidence:first.result.evidence.length});
const later=await run('later-font');
await checkClosed(later,'BATCH_BUDGET');
assert.deepEqual(later.result.outputs.comments.items.map(item=>item.item_id),['a','b']);
assert.equal(later.result.outputs.comments.truncated,true);assert.deepEqual(later.result.outputs.comments.batch_summary,{batches_read:1,duplicates_removed:0,stop_reason:'time_budget'});
assert.ok(later.result.evidence.some(item=>item.note==='有界读取第 1 批'));
results.push({name:'later screenshot cutoff exposes only committed prefix with actual first PNG',elapsed_ms:later.elapsed_ms,comments:later.result.outputs.comments.count,evidence:later.result.evidence.length});
const laterBlocked=await run('later-font-blocked');
await checkClosed(laterBlocked,['BATCH_BUDGET','WRITE_REQUEST_BLOCKED']);
assert.equal(laterBlocked.result.outputs.comments,undefined,'a blocked request during the unfinished screenshot must not be hidden by the previous committed prefix');
assert.ok(laterBlocked.result.evidence.some(item=>item.note==='有界读取第 1 批'),'the known-good first screenshot remains failure evidence');
assert.ok(laterBlocked.result.step_timings.at(-1).phase_ms.batch_capture>=500,'the delayed request must occur during actual screenshot waiting');
assert.ok(!JSON.stringify(laterBlocked.result).includes('LOCAL-LATE-QUERY'));
assert.ok(!JSON.stringify(laterBlocked.result).includes('LOCAL-LATE-BODY'));
results.push({name:'unknown POST during a later pending screenshot preserves both errors and suppresses comments',elapsed_ms:laterBlocked.elapsed_ms,evidence:laterBlocked.result.evidence.length,error_codes:laterBlocked.result.error_codes});
const global=await run('global-font',{batchBudgetMs:8000,budgetMs:4500,cleanupReserveMs:500});
await checkClosed(global,'VERIFY_BUDGET');assert.equal(global.result.outputs.comments,undefined);
results.push({name:'real screenshot interrupted by outer deadline keeps VERIFY_BUDGET priority',elapsed_ms:global.elapsed_ms});
for(const [mode,code] of [['source','SOURCE_CHANGED'],['root','SOURCE_CHANGED'],['blocked','WRITE_REQUEST_BLOCKED']]){
const rejection=await run(mode);
assert.equal(rejection.result.passed,false);assert.deepEqual(rejection.result.error_codes,[code],JSON.stringify(rejection.result.errors));
assert.equal(rejection.result.outputs.comments,undefined,'non-timeout safety failures must not expose the staged/earlier batch as successful comments');
assert.ok(rejection.result.evidence.some(item=>item.note==='有界读取第 1 批'));
await assertEvidence(rejection.result);await close(rejection.task);
results.push({name:mode+' guard is not hidden by a partial output',evidence:rejection.result.evidence.length});
}
assert.equal(writeRequests,0,'unknown POST must never reach local fixture server');assert.ok(fontRequests>=4);
const reopen=await open('normal'),capture=await dispatch('capture',{task_id:reopen.task});
assert.ok(capture.text.includes('retained'),'closed contexts preserve local profile login/cookie data');await close(reopen.task);
results.push({name:'profile survives cancellation and a new explicit ready'});
for(const invalid of [0,15001,Infinity,1.5]) await assert.rejects(()=>verify({}, {batchBudgetMs:invalid}),error=>error.code==='INVALID_INPUT');
results.push({name:'internal test budget cannot expand the 15000 ms production cap'});
await fs.writeFile(path.join(runRoot,'report.json'),JSON.stringify({passed:results.length,results,platform_requests:0,model_calls:0,write_requests:writeRequests,font_requests:fontRequests},null,2));
console.log(JSON.stringify({passed:results.length,artifacts:runRoot,browser_channel:process.env.ASSISTANT_BROWSER_CHANNEL,platform_requests:0,model_calls:0,write_requests:writeRequests}));
}catch(error){
await fs.writeFile(path.join(runRoot,'report.json'),JSON.stringify({passed:results.length,results,error:{message:error.message,code:error.code,stack:error.stack},platform_requests:0,model_calls:0,write_requests:writeRequests},null,2));
console.error(JSON.stringify({passed:results.length,artifacts:runRoot,error:error.message,code:error.code}));process.exitCode=1;
}finally{
await Promise.allSettled([...tasks].map(task=>dispatch('close',{task_id:task})));
for(const socket of sockets)socket.destroy();
await new Promise(resolve=>server.close(resolve));
}