251 lines
18 KiB
JavaScript
251 lines
18 KiB
JavaScript
// 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(`<!doctype html><html><head><title>Local scoped wait fixture</title><style>
|
|
body{font:18px sans-serif;margin:16px}#global{min-height:100px;width:620px;border:1px solid #888;padding:8px}
|
|
.title{display:block;margin:8px}#outside{min-height:30px}#tiny .title{height:1px;margin:0;font-size:1px}
|
|
</style></head><body><p id="status">Local loading transition</p>
|
|
${mode==='missing-root' ? '<section id="placeholder">Container is not yet observed</section>' : mode==='interactive-root' ? '<button id="global">Load results</button>' : '<section id="global">Results pending</section>'}
|
|
<section id="outside">Outside the fixed scope</section><script>
|
|
const mode=${JSON.stringify(mode)};
|
|
const rows=(count=2)=>Array.from({length:count},(_,i)=>'<a class="title" href="/?item='+i+'">Synthetic title '+i+'</a>').join('');
|
|
const initialRoot=document.querySelector('#global');
|
|
if(initialRoot){
|
|
const query=initialRoot.querySelectorAll.bind(initialRoot);let notified=false;
|
|
initialRoot.querySelectorAll=function(selector){
|
|
if(!notified && ['#global a.title',':scope a.title','a.title'].includes(selector)){
|
|
notified=true;fetch('/poll-ready?id='+encodeURIComponent(${JSON.stringify(id)})).catch(()=>{});
|
|
}
|
|
return query(selector);
|
|
};
|
|
}
|
|
fetch('/release?id='+encodeURIComponent(${JSON.stringify(id)})).then(r=>r.text()).then(()=>{
|
|
let root=document.querySelector('#global');
|
|
if(mode==='replace-root'){root.replaceWith(root.cloneNode(true));return;}
|
|
if(mode==='source'){history.replaceState({},'',location.pathname+'?id='+encodeURIComponent(${JSON.stringify(id)})+'&mode=changed');return;}
|
|
if(mode==='missing-root'){document.querySelector('#placeholder').id='global';return;}
|
|
if(mode==='hide-root'){root.style.display='none';return;}
|
|
if(mode==='sensitive-root'){root.setAttribute('aria-label','verification code');return;}
|
|
if(mode==='sensitive-row'){root.insertAdjacentHTML('beforeend','<a class="title" aria-label="verification code" href="/?item=private">Synthetic private field</a>');return;}
|
|
if(mode==='too-many'){root.id='tiny';root.insertAdjacentHTML('beforeend',rows(101));root.id='global';return;}
|
|
if(mode==='outside-only'){document.querySelector('#outside').insertAdjacentHTML('beforeend',rows());return;}
|
|
if(mode==='one-inside'){root.insertAdjacentHTML('beforeend',rows(1));document.querySelector('#outside').insertAdjacentHTML('beforeend',rows());return;}
|
|
if(mode==='blocked'){fetch('/unknown-write?query=LOCAL_SCOPED_QUERY',{method:'POST',body:'LOCAL_SCOPED_BODY'}).catch(()=>{});return;}
|
|
if(mode==='never')return;
|
|
root.insertAdjacentHTML('beforeend',rows());
|
|
if(mode==='poll-dom-block'){
|
|
for(const row of root.querySelectorAll('a.title')){
|
|
const rect=row.getBoundingClientRect.bind(row);let blocked=false;
|
|
row.getBoundingClientRect=()=>{if(!blocked){blocked=true;const until=Date.now()+4000;while(Date.now()<until){}}return rect();};
|
|
}
|
|
}
|
|
if(mode==='outside-plus-inside')document.querySelector('#outside').insertAdjacentHTML('beforeend',rows(1));
|
|
if(mode==='final-font'){
|
|
const font=new FontFace('scoped-wait-font','url(/pending-font)');document.fonts.add(font);font.load().catch(()=>{});
|
|
const span=document.createElement('span');span.style.fontFamily='scoped-wait-font';span.textContent='Pending final screenshot';document.body.append(span);
|
|
}
|
|
});
|
|
</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 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()<until){}return 'late';},{budgetMs:25,cleanupReserveMs:5,onTimeout:()=>{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));
|
|
}
|