// 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 fixture

Local loading transition

${mode==='missing-root' ? '
Container is not yet observed
' : mode==='interactive-root' ? '' : '
Results pending
'}
Outside the fixed scope
`); }); 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)); }