// Finite scoped-wait regression. Browser navigation is restricted to our own
// loopback fixture; official-origin cases below use synthetic helper metadata.
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';
import {setTimeout as delay} from 'node:timers/promises';
import {READONLY_POLICY_VERSION,endPlatformRequestPolicy,matchPlatformRequest} from './platform-policy.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..');
await fs.mkdir(path.join(root,'.tmp'),{recursive:true});
const output=await fs.mkdtemp(path.join(root,'.tmp','scoped-wait-'));
process.env.ASSISTANT_LEARNING_HOME=path.join(output,'learning');
process.env.ASSISTANT_BROWSER_HEADLESS='1';
process.env.ASSISTANT_BROWSER_CHANNEL=process.platform==='win32' ? 'msedge' : 'chromium';
const {dispatch,verify,preflightReadonlyWait,withWaitDeadline,withVerifyDeadline}=await import('./browser-worker.mjs');
const results=[],tasks=new Set(),pending=new Map(),polled=new Set(),sockets=new Set();
let serial=0,fixtureRequests=0,fixtureWrites=0,fontRequests=0,assertions=0;
const eq=(actual,expected,message)=>{assert.deepEqual(actual,expected,message);assertions+=1;};
const ok=(value,message)=>{assert.ok(value,message);assertions+=1;};
const save=()=>fs.writeFile(path.join(output,'report.json'),JSON.stringify({passed:results.length,assertions,results,
live_platform_requests:0,official_browser_navigation:false,application_api_calls:0,model_calls:0,
fixture_requests:fixtureRequests,fixture_writes:fixtureWrites,font_requests:fontRequests},null,2));
const passed=async(name,details={})=>{results.push({name,...details});await save();};
const server=createServer((request,response)=>{
fixtureRequests+=1;
const url=new URL(request.url,'http://127.0.0.1');
if(request.method!=='GET'){fixtureWrites+=1;response.writeHead(405).end();return;}
if(url.pathname==='/pending-font'){fontRequests+=1;return;}
if(url.pathname==='/release'){pending.set(url.searchParams.get('id'),response);return;}
if(url.pathname==='/poll-ready'){polled.add(url.searchParams.get('id'));response.end('observed');return;}
if(url.pathname!=='/fixture'){response.writeHead(404).end();return;}
const id=url.searchParams.get('id'),mode=url.searchParams.get('mode');
response.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});
response.end(`
Local scoped wait fixtureLocal loading transition
${mode==='missing-root' ? 'Container is not yet observed' : mode==='interactive-root' ? '' : ''}
`);
});
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 open=async mode=>{
const task=`scoped-wait-${++serial}`,url=`${base}?id=${serial}&mode=${mode}`;
tasks.add(task);
await dispatch('open',{task_id:task,account_key:'isolated-scoped-wait',entry_url:url});
await dispatch('ready',{task_id:task,identity_label:'Local scoped wait'});
const before=await dispatch('capture',{task_id:task});
for(let index=0;!pending.has(String(serial)) && index<100;index+=1)await delay(10);
ok(pending.has(String(serial)),'fixture release is pending before an action');
return {task,id:String(serial),before,url};
};
const close=async task=>{await dispatch('close',{task_id:task});tasks.delete(task);};
const release=fixture=>{const response=pending.get(fixture.id);ok(response,'release is controlled by the loopback fixture');response.end('release');pending.delete(fixture.id);};
const png=async capture=>{ok(capture?.screenshot_path,'a real capture path exists');const file=await fs.readFile(capture.screenshot_path);eq(file.subarray(1,4).toString(),'PNG');};
const waitParams={kind:'wait',wait_scope:'#global',selector:'#global a.title',state:'visible',min_count:2,timeout_ms:3000};
const action=async(fixture,patch={},beforeAction=false)=>{
if(beforeAction){release(fixture);await delay(100);}
let settled=false;
const started=Date.now();
const promise=dispatch('action',{task_id:fixture.task,...waitParams,...patch});
void promise.then(()=>{settled=true;},()=>{settled=true;});
if(!beforeAction){
// Synchronize on the fixture's first actual root-relative query rather
// than a wall-clock guess while capture_before may still be running.
if(Object.hasOwn(patch,'wait_scope') && patch.wait_scope===undefined)await delay(150);
else{
for(let index=0;!polled.has(fixture.id) && !settled && index<250;index+=1)await delay(10);
ok(polled.has(fixture.id),'the first real scoped collection query has executed');
}
eq(settled,false,'initial zero rows do not satisfy the wait');release(fixture);
}
try{return {value:await promise,elapsed_ms:Date.now()-started};}
catch(error){return {error,elapsed_ms:Date.now()-started};}
};
const errorRecord=error=>({code:error.code,message:error.message,session_closed:error.session_closed===true,
...(error.error_codes ? {error_codes:error.error_codes} : {}),before:error.before,after:error.after,diagnostics:error.diagnostics});
const rejected=async(mode,code,{patch={},beforeAction=false,closed=false}={})=>{
const fixture=await open(mode),result=await action(fixture,patch,beforeAction);
if(result.error)await fs.writeFile(path.join(output,mode+'.json'),JSON.stringify({...errorRecord(result.error),elapsed_ms:result.elapsed_ms},null,2));
ok(result.error,`${mode} must fail`);eq(result.error.code,code,`${mode} code`);
if(closed){eq(result.error.session_closed,true,'only actual context close sets session_closed');eq((await dispatch('status',{task_id:fixture.task})).status,'closed');ok(result.elapsed_ms<2500,'scoped wait cancels instead of waiting for a 10s operation');}
await png(result.error.before);
if(result.error.after)await png(result.error.after);
const record=errorRecord(result.error),saved=JSON.stringify(record);
await delay(150);eq(JSON.stringify(record),saved,'late callbacks do not mutate the returned evidence');
await fs.writeFile(path.join(output,mode+'.json'),JSON.stringify({...record,elapsed_ms:result.elapsed_ms},null,2));
await close(fixture.task);return result;
};
try{
let cleanup=0;
eq(await withWaitDeadline(async()=>17,{budgetMs:100,cleanupReserveMs:10,onTimeout:()=>{cleanup+=1;}}),17);
let rejectPending;
await assert.rejects(()=>withWaitDeadline(()=>new Promise((_,reject)=>{rejectPending=reject;}),{budgetMs:60,cleanupReserveMs:10,onTimeout:()=>{cleanup+=1;rejectPending(new Error('cancelled fixture'));}}),error=>error.code==='WAIT_TIMEOUT');assertions+=1;
eq(cleanup,1);
await assert.rejects(()=>withWaitDeadline(async()=>{const until=Date.now()+45;while(Date.now(){cleanup+=1;}}),error=>error.code==='WAIT_TIMEOUT');assertions+=1;
eq(cleanup,2,'late resolution is rejected with exactly one cleanup');
await assert.rejects(()=>withVerifyDeadline(()=>withWaitDeadline(()=>new Promise(()=>{}),{budgetMs:500,cleanupReserveMs:20}),{budgetMs:50,cleanupReserveMs:10,onTimeout:()=>{}}),error=>error.code==='VERIFY_BUDGET');assertions+=1;
await passed('wait helper normal, cancellation, late resolution and outer budget priority');
const official='https://www.xiaohongshu.com',pageUrl=official+'/search_result_ai?keyword=synthetic';
const rootState={tag:'section',readonly_root:true,sensitive:false,forbidden:false,visible:true,in_viewport:true};
const context={sessionOrigin:official,pageUrl,kind:'wait',selector:'#global',selectorObserved:true,
observed:{kind:'read',tag:'section',unique:true,sensitive:false},actualRoots:[rootState],now:10000};
const policy=preflightReadonlyWait(context);
eq(policy?.scope,'readonly_ui');eq(policy?.version,READONLY_POLICY_VERSION);
const request={policy,automating:true,sessionOrigin:official,pageUrl,activeMainFrame:true,method:'POST',resourceType:'xhr',now:10001};
eq(matchPlatformRequest({...request,url:'https://t2.xiaohongshu.com/api/v2/collect'}),{policy_version:READONLY_POLICY_VERSION,policy_rule:'collect'});
eq(matchPlatformRequest({...request,url:'https://apm-fe.xiaohongshu.com/api/data',resourceType:'fetch'}),{policy_version:READONLY_POLICY_VERSION,policy_rule:'apm_data'});
for(const patch of [
{url:'https://apm-fe.xiaohongshu.com/api/data'},
{url:'https://as.xiaohongshu.com/api/sec/v1/shield/webprofile'},
{url:'https://so.xiaohongshu.com/api/sns/web/v2/search/notes'},
{url:'https://edith.xiaohongshu.com/api/sns/web/v2/comment/post'},
{url:'https://example.invalid/unknown-write'},
{url:'https://t2.xiaohongshu.com/api/v2/collect',activeMainFrame:false},
{url:'https://t2.xiaohongshu.com/api/v2/collect',now:25001},
])eq(matchPlatformRequest({...request,...patch}),null);
eq(matchPlatformRequest({...request,policy:{...policy},url:'https://t2.xiaohongshu.com/api/v2/collect'}),null);
endPlatformRequestPolicy(policy);
eq(matchPlatformRequest({...request,url:'https://t2.xiaohongshu.com/api/v2/collect'}),null);
await passed('actual root policy metadata retains only the original two readonly request rules');
for(const [mode,selector] of [['normal','#global a.title'],['relative',':scope a.title'],['outside-plus-inside','a.title']]){
const fixture=await open(mode);
const observed=fixture.before.elements.find(element=>element.selector==='#global');
eq(observed?.kind,'read');eq(observed?.unique,true);
eq(fixture.before.elements.some(element=>element.selector==='#global a.title'),false,'a zero-item collection is not invented');
const {value,error}=await action(fixture,{selector});
ok(!error,`${mode}: ${error?.code} ${error?.message}`);eq(value.result.satisfied,true);eq(value.result.min_count,2);
eq(value.result.wait_scope,'#global');
ok(value.after.elements.some(element=>element.selector==='#global a.title' && element.count===2),'full final capture establishes the new real collection');
for(const capture of [value.before,value.after])await png(capture);
const read=await dispatch('action',{task_id:fixture.task,kind:'read',selector:'#global a.title',output_name:'titles'});
eq(read.result.length,2,'subsequent read consumes the newly observed collection');
await fs.writeFile(path.join(output,mode+'.json'),JSON.stringify(value,null,2));
await close(fixture.task);
await passed(mode+' zero-to-two transition and subsequent read',{selector});
}
for(const mode of ['outside-only','one-inside']){
const result=await rejected(mode,'WAIT_TIMEOUT',{patch:{selector:'a.title',timeout_ms:1200},closed:true});
await passed(mode+' outside matches never satisfy min_count',{elapsed_ms:result.elapsed_ms});
}
for(const mode of ['replace-root','source']){
await rejected(mode,'SOURCE_CHANGED');await passed(mode+' during wait is pinned and rejected');
}
await rejected('sensitive-row','SENSITIVE_OUTPUT');await passed('sensitive descendant is rejected');
await rejected('too-many','READ_LIMIT');await passed('more than 100 scoped rows is rejected');
for(const mode of ['missing-root','hide-root','interactive-root']){
await rejected(mode,'UNOBSERVED_CURRENT_SELECTOR',{beforeAction:true});await passed(mode+' cannot become an observed ordinary root');
}
await rejected('sensitive-root','SENSITIVE_OUTPUT');await passed('root becoming sensitive during wait is rejected');
const blocked=await rejected('blocked','WRITE_REQUEST_BLOCKED');
ok(!JSON.stringify(errorRecord(blocked.error)).includes('LOCAL_SCOPED_QUERY'));
ok(!JSON.stringify(errorRecord(blocked.error)).includes('LOCAL_SCOPED_BODY'));
eq(fixtureWrites,0,'unknown POST never reaches the fixture server');
await passed('unknown POST is blocked, aborts the wait and remains redacted');
const oldFixture=await open('normal');
const old=await action(oldFixture,{wait_scope:undefined});
ok(!old.error,JSON.stringify(old.error));eq(old.value.result.satisfied,true);
eq(old.value.result.wait_scope,undefined);await png(old.value.after);await close(oldFixture.task);
await passed('existing unscoped loopback demo wait remains compatible');
const timeout=await rejected('never','WAIT_TIMEOUT',{patch:{timeout_ms:1200},closed:true});
await passed('real empty wait reaches its hard deadline and actually closes context',{elapsed_ms:timeout.elapsed_ms});
const dom=await rejected('poll-dom-block','WAIT_TIMEOUT',{patch:{timeout_ms:2000},closed:true});
await passed('in-flight scoped row DOM evaluation is cancelled by the wait deadline',{elapsed_ms:dom.elapsed_ms});
// Post-condition evidence uses the existing general screenshot pipeline,
// outside the wait policy. Its separate outer verification budget remains
// authoritative while an actual local font stalls the final screenshot.
const fontFixture=await open('final-font');
const started=Date.now();
const fontTimer=setInterval(()=>{if(polled.has(fontFixture.id) && pending.has(fontFixture.id))release(fontFixture);},20);
let font;
try{
font=await verify({task_id:fontFixture.task,entry_url:fontFixture.url,parameters:{},
steps:[{...waitParams,timeout_ms:2500},{kind:'read',selector:'#global a.title',output_name:'titles'}],
expected_outputs:{titles:'Synthetic title'}},{budgetMs:4000,cleanupReserveMs:500});
}finally{clearInterval(fontTimer);}
eq(font.passed,false);eq(font.error_codes,['VERIFY_BUDGET']);eq(font.session_closed,true);
eq((await dispatch('status',{task_id:fontFixture.task})).status,'closed');
ok(fontRequests>0,'the actual screenshot waits on our controlled local font');
ok(Date.now()-started<6500,'verification cancels the 10s final screenshot wait');
ok(font.step_timings.some(timing=>timing.phase_ms.capture_after>0),'the stalled operation is final evidence capture');
for(const capture of font.evidence)await png(capture);
const stable=JSON.stringify(font);await delay(150);eq(JSON.stringify(font),stable,'late screenshot completion never changes the verification result');
await fs.writeFile(path.join(output,'final-font.json'),JSON.stringify(font,null,2));
await close(fontFixture.task);
await passed('final actual screenshot remains covered by the outer verification deadline',{elapsed_ms:Date.now()-started});
eq(fixtureWrites,0);await save();
console.log(JSON.stringify({passed:results.length,assertions,output,live_platform_requests:0,model_calls:0,fixture_writes:fixtureWrites}));
}catch(error){
await save();
const report=JSON.parse(await fs.readFile(path.join(output,'report.json'),'utf8'));
report.error={message:error.message,code:error.code,stack:error.stack};
await fs.writeFile(path.join(output,'report.json'),JSON.stringify(report,null,2));
console.error(JSON.stringify({passed:results.length,assertions,output,error:error.message,code:error.code}));process.exitCode=1;
}finally{
await Promise.allSettled([...tasks].map(task=>dispatch('close',{task_id:task})));
for(const response of pending.values())response.end('closed');
for(const socket of sockets)socket.destroy();
await new Promise(resolve=>server.close(resolve));
}