import assert from 'node:assert/strict';
// All pages and mutations here are isolated fixtures. The ordinary fixed root
// deliberately has no scrolling overflow; only the document viewport can move.
export function viewportFixture(url,request,response){
if(url.pathname!=='/fixture/viewport-seek') return false;
const mode=url.searchParams.get('mode') || 'offscreen';
const initialTarget=['visible','obscured'].includes(mode) ? 80 : 1100;
const anchor=(top=initialTarget)=>`目标笔记`;
const noTarget=['missing','time','lazy'].includes(mode);
const change=mode==='source' ? `history.replaceState({},'',location.pathname+'?changed=1')` :
mode==='root' ? `root.replaceWith(root.cloneNode(true))` :
mode==='scroller' ? `Object.defineProperty(document,'scrollingElement',{configurable:true,get:()=>document.body})` :
mode==='disappear' ? `root.querySelector('.viewport-title').remove()` :
mode==='blocked' ? `fetch('/fixture/write/viewport-seek',{method:'POST'}).catch(()=>{})` :
mode==='lazy' ? `setTimeout(()=>root.insertAdjacentHTML('beforeend',${JSON.stringify(anchor(500))}),250)` : '';
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`
固定根内的视口有界定位实际视口定位样本
普通已加载内容,根容器不独立滚动
${noTarget ? '' : anchor()}${mode==='duplicate' ? anchor(1140) : ''}目标笔记${mode==='obscured' ? '遮挡目标
' : ''}`);
return true;
}
function checkSummary(result,{passed,reason,code,movement=false,zero=false}){
assert.equal(result.passed,passed,JSON.stringify(result.errors));
assert.deepEqual(result.outputs,{status:[{text:'实际视口定位样本'}]},'the real read prefix is retained even when the later seek fails');
assert.equal(result.seek_results.length,1);
const summary=result.seek_results[0];
assert.deepEqual(Object.keys(summary).sort(),['step_position','scrolls','movements','target_reached','stop_reason'].sort());
assert.equal(summary.step_position,2);assert.equal(summary.target_reached,passed);assert.equal(summary.stop_reason,reason);
assert.equal(summary.scrolls,summary.movements.length);
assert.ok(summary.movements.every(move=>Object.keys(move).sort().join(',')==='after,before,client_height,scroll_height' &&
Object.values(move).every(Number.isFinite) && move.before>=0 && move.after>=move.before && move.client_height>180 && move.scroll_height>move.client_height));
for(let index=1;index0);assert.ok(summary.movements.some(move=>move.after>move.before));}
if(zero){assert.equal(summary.scrolls,0);assert.deepEqual(summary.movements,[]);}
assert.equal(result.verify_budget_ms,undefined,'explicit viewport scope does not expand the ordinary verify budget');
assert.deepEqual(result.error_codes,code ? [code] : [],JSON.stringify(result.errors));
assert.equal(result.step_timings[0].status,'passed');
assert.equal(result.step_timings[1].status,passed ? 'passed' : 'failed');
assert.ok(result.evidence.length>=(passed ? 3 : 4),'real entry, prefix and final/failed captures are retained');
return summary;
}
export async function viewportRegression(client,entryUrl,passed){
const task='viewport-seek-regression',base=new URL('/fixture/viewport-seek',entryUrl).href;
await client.call('open',{task_id:task,account_key:'test-account',entry_url:base});
await client.call('ready',{task_id:task,identity_label:'隔离视口定位测试'});
const seek={kind:'scroll',selector:'#viewport-root',scroll_scope:'viewport',direction:'down',pixels:320,max_scrolls:5,timeout_ms:3000,
until_target:{selector:'#viewport-global a.viewport-title',field:'text',value:'目标笔记'}};
// A required output is read before seeking, since after a genuine document
// movement this first-screen status is intentionally no longer in view.
const run=(mode,extra={})=>client.call('verify',{task_id:task,entry_url:base+'?mode='+mode,parameters:{},
steps:[{kind:'read',selector:'#viewport-status',output_name:'status'},{...seek,...extra}],expected_outputs:{status:'实际视口定位样本'}});
try{
const actual=await run('offscreen');
const summary=checkSummary(actual,{passed:true,reason:'target_reached',movement:true});
assert.ok(summary.scrolls<=seek.max_scrolls);
assert.equal(summary.movements[0].before,0);assert.equal(summary.movements[0].after,seek.pixels);
assert.ok(summary.movements.every(move=>move.after-move.before<=seek.pixels),'fixed viewport movements cannot jump directly to a target');
const final=actual.evidence.at(-1);
assert.equal(final.viewport.scroll_y,summary.movements.at(-1).after,'evidence records the same actual window displacement');
assert.equal(final.elements.find(element=>element.selector==='#viewport-root')?.scroll_y,undefined,'ordinary root never becomes an implicit inner scroll container');
checkSummary(await run('visible'),{passed:true,reason:'target_reached',zero:true});
checkSummary(await run('offscreen',{scroll_scope:undefined}),{passed:false,code:'UNOBSERVED_CURRENT_SELECTOR',reason:'target_changed',zero:true});
passed('显式viewport在固定普通根内匹配并实际固定步长滚document,证据与真实metrics一致;已可见零移动,旧未声明scope仍拒绝非滚动根');
for(const [mode,code,reason,extra] of [
['missing','SCROLL_BUDGET_EXHAUSTED','scroll_budget',{max_scrolls:1}],
['missing','SCROLL_BOUNDARY_BEFORE_TARGET','boundary',{pixels:1600,max_scrolls:3}],
['disappear','SCROLL_BUDGET_EXHAUSTED','scroll_budget',{max_scrolls:1}],
['time','SCROLL_TARGET_TIMEOUT','time_budget',{timeout_ms:1}],
]){
const failure=await run(mode,extra);
const item=checkSummary(failure,{passed:false,code,reason,movement:['missing','disappear'].includes(mode)});
if(reason==='boundary') assert.equal(item.movements.at(-1).after,item.movements.at(-1).before,'boundary summary records the real no-movement attempt');
}
const lazy=await run('lazy',{max_scrolls:1,timeout_ms:2000});
const lazySummary=checkSummary(lazy,{passed:true,reason:'target_reached',movement:true});
assert.equal(lazySummary.scrolls,1);
passed('视口目标缺失或消失不接受根外可见同名链接,滚动/边界/时间预算分别停止;短等允许根内自然出现且不重复滚动');
for(const [mode,code,reason,movement] of [
['duplicate','NON_UNIQUE_TARGET','ambiguous',false],
['obscured','TARGET_OBSCURED','obscured',false],
['source','SCROLL_SOURCE_CHANGED','source_changed',true],
['root','STALE_TARGET','target_changed',true],
['scroller','STALE_TARGET','target_changed',true],
['blocked','WRITE_REQUEST_BLOCKED','blocked_request',true],
]){
const failure=await run(mode);
checkSummary(failure,{passed:false,code,reason,movement,zero:!movement});
}
passed('视口重名/遮挡不滚动,来源/固定根/实际scrollingElement身份变化和未知POST中断;失败保留真实阅读前缀、位移与截图');
}finally{
await client.call('close',{task_id:task});
}
}