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(`
与目标无关的已加载文本'+index+'
').join('')}`); 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根检查与真实批次截图;固定容器替换继续中断并保留已得证据'); }