153 lines
12 KiB
JavaScript
153 lines
12 KiB
JavaScript
// 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(`<!doctype html><html><head><title>Local batch deadline fixture</title><style>
|
|
body{margin:10px}#comments-root{width:520px}#comments-window{height:170px;width:500px;overflow:auto}
|
|
.comment-row{height:80px;box-sizing:border-box}.comment-author{display:block;height:20px}.comment-body{height:30px}
|
|
</style></head><body><p id="detail">Already read local detail</p><p id="profile-check">${request.headers.cookie?.includes('batch-fixture-session=retained') ? 'retained' : 'fresh'}</p>
|
|
<section id="comments-root"><div id="comments-window">${['a','b','c','d','e'].map(id=>`<article class="comment-row" data-item-id="${id}"><span class="comment-author">Author ${id}</span><div class="comment-body">Comment ${id}</div></article>`).join('')}</div></section>
|
|
<script>
|
|
const mode=${JSON.stringify(mode)},view=document.querySelector('#comments-window');let started=false;
|
|
const fontWait=()=>{if(started)return;started=true;const font=new FontFace('batch-wait','url(/pending-font)');document.fonts.add(font);font.load().catch(()=>{});const span=document.createElement('span');span.textContent='Pending screenshot font';span.style.fontFamily='batch-wait';document.body.append(span);};
|
|
if(mode==='first-font' || mode==='global-font'){
|
|
for(const row of document.querySelectorAll('.comment-row')){const get=row.getAttribute.bind(row);row.getAttribute=function(name){if(name==='data-item-id')fontWait();return get(name);};}
|
|
}
|
|
view.addEventListener('scroll',()=>{
|
|
if(mode==='later-font'){fontWait();return;}
|
|
if(mode==='later-font-blocked'){fontWait();setTimeout(()=>fetch('/write?token=LOCAL-LATE-QUERY',{method:'POST',body:'LOCAL-LATE-BODY'}).catch(()=>{}),500);return;}
|
|
if(started)return;started=true;
|
|
if(mode==='source')history.replaceState({},'',location.pathname+'?mode=changed');
|
|
if(mode==='root')view.replaceWith(view.cloneNode(true));
|
|
if(mode==='blocked')fetch('/write?token=LOCAL-QUERY',{method:'POST',body:'LOCAL-BODY'}).catch(()=>{});
|
|
},{once:true});
|
|
</script></body></html>`);
|
|
});
|
|
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()<end){}finished=true;return 'late';},{budgetMs:25,cleanupReserveMs:5,onTimeout:()=>{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));
|
|
}
|