74 lines
7.8 KiB
JavaScript
74 lines
7.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import path from 'node:path';
|
||
import {chromium} from 'playwright';
|
||
import {describePage} from './browser-worker.mjs';
|
||
|
||
export function metadataFixture(url,request,response){
|
||
if(url.pathname!=='/fixture/target-metadata') return false;
|
||
response.setHeader('Content-Type','text/html; charset=utf-8');
|
||
response.end(`<!doctype html><html><head><title>局部当前对象检查</title><style>body{margin:10px}#metadata-scroll{height:100px;overflow:auto;width:500px}.metadata-row{height:80px}</style></head><body><form method="get"><label for="metadata-search">搜索资料</label><input id="metadata-search" type="search" name="keyword" aria-label="搜索资料" placeholder="搜索示例0"><button>搜索</button></form><section id="metadata-root"><div id="metadata-scroll"><article class="metadata-row">第一条内容</article><article class="metadata-row">第二条内容</article></div><a class="title" href="/fixture/target-metadata?item=one">标题甲</a><a class="title" href="/fixture/target-metadata?item=two">标题乙</a></section><div id="access-token">不应返回的凭据容器</div>${Array.from({length:500},(_,index)=>'<p>与目标无关的已加载文本'+index+'</p>').join('')}</body></html>`);
|
||
return true;
|
||
}
|
||
|
||
export async function metadataRegression(client,entryUrl,passed,home,channel,semanticChanges){
|
||
const context=await chromium.launchPersistentContext(path.join(home,'target-metadata-isolated'),{channel,headless:true,viewport:{width:1280,height:840}});
|
||
try{
|
||
const page=context.pages()[0] || await context.newPage();await page.goto(new URL('/fixture/target-metadata',entryUrl).href);
|
||
const full=await describePage(page);
|
||
const keys=['selector','tag','label','kind','unique','count','sensitive','in_viewport','search_input','stable_label','scroll_y'];
|
||
const core=value=>Object.fromEntries(keys.filter(key=>Object.hasOwn(value,key)).map(key=>[key,value[key]]));
|
||
await page.evaluate(()=>{
|
||
const original=document.querySelectorAll;
|
||
document.querySelectorAll=function(selector){
|
||
if(['body *','input,textarea,button,a,[data-testid],[data-learning-read],main,article,[role=main],[role=button]','body [id],h1,h2,h3,h4,h5,h6,section,[role=heading],[role=region],[role=list],[role=listitem]'].includes(selector)) throw Error('FULL_PAGE_DISCOVERY_FORBIDDEN');
|
||
return original.call(this,selector);
|
||
};
|
||
});
|
||
for(const selector of ['#metadata-search','#metadata-root','#metadata-scroll','#metadata-root a.title']){
|
||
const observed=full.elements.find(element=>element.selector===selector);assert.ok(observed,selector+' must be observed by normal full capture');
|
||
const current=await describePage(page,{selector,collection:observed.collection===true,requireCanonical:!observed.collection});
|
||
assert.deepEqual(core(current.elements[0]),core(observed),selector+' keeps identical metadata without global discovery');
|
||
}
|
||
assert.deepEqual((await describePage(page,{selector:'#not-present'})).elements,[]);
|
||
assert.deepEqual((await describePage(page,{selector:'#access-token'})).elements,[]);
|
||
await page.evaluate(()=>document.querySelector('#metadata-search').placeholder='另一个搜索示例');
|
||
const rotated=(await describePage(page,{selector:'#metadata-search',requireCanonical:true})).elements[0];
|
||
assert.equal(rotated.stable_label,full.elements.find(element=>element.selector==='#metadata-search').stable_label);
|
||
await page.evaluate(()=>document.querySelector('#metadata-search').setAttribute('data-testid','new-search-target'));
|
||
assert.deepEqual((await describePage(page,{selector:'#metadata-search',requireCanonical:true})).elements,[],'canonical locator changes remain unobserved');
|
||
await page.evaluate(()=>{const root=document.querySelector('#metadata-root');root.setAttribute('aria-label','secret credential');});
|
||
assert.deepEqual((await describePage(page,{selector:'#metadata-root',requireCanonical:true})).elements,[],'newly sensitive roots cannot retain prior ordinary metadata');
|
||
}finally{await context.close();}
|
||
passed('已观察selector局部元数据与整页原构造一致,禁止全页枚举仍可检查;固定搜索语义/普通滚动根/集合/敏感与canonical变化保持');
|
||
|
||
const semanticUrl=new URL('/fixture/search-semantics',entryUrl).href;
|
||
for(const mode of ['placeholder','aria','intent','missing']){
|
||
const task='narrow-current-'+mode;
|
||
await client.call('open',{task_id:task,account_key:'test-account',entry_url:semanticUrl+'?mode='+mode});
|
||
await client.call('ready',{task_id:task,identity_label:'局部元数据搜索回归'});await client.call('capture',{task_id:task});
|
||
if(mode==='placeholder'){
|
||
await new Promise(resolve=>setTimeout(resolve,160));
|
||
await client.call('action',{task_id:task,kind:'fill',selector:'#semantic-search',value:'装修'});
|
||
const entered=await client.call('action',{task_id:task,kind:'press',selector:'#semantic-search',key:'Enter'});assert.ok(entered.after.text.includes('装修 的结果'));
|
||
}else{
|
||
const response=semanticChanges.get(mode);assert.ok(response);response.writeHead(200,{'Content-Type':'application/json'}).end('{}');await new Promise(resolve=>setTimeout(resolve,160));
|
||
await assert.rejects(()=>client.call('action',{task_id:task,kind:'fill',selector:'#semantic-search',value:'不可执行'}),error=>error.code===(mode==='missing' ? 'UNOBSERVED_CURRENT_SELECTOR' : 'STALE_TARGET'));
|
||
}
|
||
await client.call('close',{task_id:task});
|
||
}
|
||
const delayedTask='narrow-zero-visible-wait',delayed=new URL('/fixture/delayed-collection',entryUrl).href;
|
||
await client.call('open',{task_id:delayedTask,account_key:'test-account',entry_url:delayed});await client.call('ready',{task_id:delayedTask,identity_label:'局部零集合等待'});
|
||
const waiting=await client.call('verify',{task_id:delayedTask,entry_url:delayed,parameters:{},steps:[{kind:'wait',selector:'#delayed-titles a.title',state:'visible',min_count:1,timeout_ms:5000},{kind:'read',selector:'#delayed-titles',output_name:'titles'}],expected_outputs:{titles:'标题甲'}});
|
||
assert.equal(waiting.passed,true,JSON.stringify(waiting.errors));await client.call('close',{task_id:delayedTask});
|
||
passed('局部检查后placeholder仍可fill/Enter,aria/意图/canonical变化仍拒绝;初始0项集合由原wait轮询实际等待出现');
|
||
|
||
const batchTask='narrow-current-batch',batchUrl=new URL('/fixture/batch-read',entryUrl).href;
|
||
await client.call('open',{task_id:batchTask,account_key:'test-account',entry_url:batchUrl});await client.call('ready',{task_id:batchTask,identity_label:'局部元数据批次根'});
|
||
const extraction={kind:'extract',selector:'#batch-container',row_selector:'.batch-row',output_name:'comments',complete_only:true,limit:5,fields:[{name:'comment_id',source:'attribute',attribute:'data-item-id',required:true},{name:'author',selector:'.batch-author',source:'text',required:true},{name:'body',selector:'.batch-body',source:'text',required:true}],batch:{scroll_selector:'#batch-window',pixels:160,max_batches:3,dedupe_field:'comment_id'}};
|
||
const normal=await client.call('verify',{task_id:batchTask,entry_url:batchUrl,parameters:{},steps:[extraction],expected_outputs:{comments:{state:'nonempty'}}});assert.equal(normal.passed,true,JSON.stringify(normal.errors));assert.ok(normal.outputs.comments.count>=3);
|
||
const replaced=await client.call('verify',{task_id:batchTask,entry_url:batchUrl+'?mode=root',parameters:{},steps:[extraction],expected_outputs:{comments:{state:'nonempty'}}});
|
||
assert.equal(replaced.passed,false);assert.ok(replaced.error_codes.includes('SOURCE_CHANGED') || replaced.error_codes.includes('STALE_TARGET'));assert.equal(replaced.outputs.comments,undefined);assert.ok(replaced.evidence.length>=3);
|
||
await client.call('close',{task_id:batchTask});
|
||
passed('滚动selector独立局部复核保留readonly根检查与真实批次截图;固定容器替换继续中断并保留已得证据');
|
||
}
|