assistant/tools/learning/test-target-metadata.mjs

74 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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根检查与真实批次截图;固定容器替换继续中断并保留已得证据');
}