105 lines
8.1 KiB
JavaScript
105 lines
8.1 KiB
JavaScript
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)=>`<a class="viewport-title" style="top:${top}px" href="/fixture/viewport-seek?selected=1">目标笔记</a>`;
|
||
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(`<!doctype html><html><head><title>固定根内的视口有界定位</title><style>
|
||
body{margin:12px}#viewport-status{height:32px;margin:0}#viewport-root{position:relative;width:500px;height:2400px;overflow:visible}
|
||
.viewport-title{position:absolute;display:block;left:0;width:400px;height:30px;box-sizing:border-box}
|
||
#viewport-outside{position:fixed;top:12px;left:700px;width:200px;height:30px}
|
||
#viewport-cover{position:fixed;top:120px;left:12px;width:500px;height:100px;background:#eee;z-index:10}
|
||
</style></head><body><p id="viewport-status">实际视口定位样本</p><main id="viewport-global"><section id="viewport-root"><p>普通已加载内容,根容器不独立滚动</p>${noTarget ? '' : anchor()}${mode==='duplicate' ? anchor(1140) : ''}</section><a id="viewport-outside" class="viewport-title" href="/fixture/viewport-seek?outside=1">目标笔记</a></main>${mode==='obscured' ? '<div id="viewport-cover">遮挡目标</div>' : ''}<script>
|
||
const root=document.querySelector('#viewport-root');let started=false;
|
||
addEventListener('scroll',()=>{if(started)return;started=true;${change}});
|
||
</script></body></html>`);
|
||
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;index<summary.movements.length;index+=1) assert.equal(summary.movements[index].before,summary.movements[index-1].after,'actual document movements are consecutive');
|
||
if(movement){assert.ok(summary.scrolls>0);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});
|
||
}
|
||
}
|