101 lines
10 KiB
JavaScript
101 lines
10 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import {validateAction} from './action-contract.mjs';
|
||
import {validContentValue,validNonemptyExtractOutput} from './browser-worker.mjs';
|
||
|
||
const phases=['validate','inspect_current','capture_before','resolve_target','execute','settle','request_guard','capture_after','failure_capture','batch_capture'];
|
||
|
||
export function contentFixture(url,request,response){
|
||
if(url.pathname==='/fixture/content-loading'){response.writeHead(404,{'Content-Type':'image/png'}).end('not an image');return true;}
|
||
if(url.pathname==='/fixture/content-image'){
|
||
response.writeHead(200,{'Content-Type':'image/svg+xml'}).end('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><rect width="120" height="80" fill="#6a8"/></svg>');return true;
|
||
}
|
||
if(url.pathname!=='/fixture/content-comments') return false;
|
||
const mode=url.searchParams.get('mode') || 'text';
|
||
const image=(extra='')=>`<img class="content-media" width="120" height="80" src="/fixture/${mode.includes('lazy') || mode==='loading' ? 'content-loading' : 'content-image?token=RAW-MEDIA-SECRET'}" ${extra}>`;
|
||
const row=(id,body)=>`<article class="content-row" id="content-${id}"><a class="content-author" href="/fixture/content-comments">合成作者</a><div class="content-root">${body}</div></article>`;
|
||
const text='<span class="content-text">真实文字,装修需求</span>';
|
||
let body=mode==='text' ? text : mode==='media' || mode.includes('lazy') || mode==='loading' ? image() : mode==='mixed' ? text+image() :
|
||
mode==='hidden' ? image('style="display:none"') : mode==='avatar' ? '<a class="avatar" href="/fixture/content-comments">'+image()+'</a>' :
|
||
mode==='sensitive' ? '<div id="access-token">'+image()+'</div>' : mode==='multiple' ? text+text : mode==='many' ? Array.from({length:5},()=>image()).join('') :
|
||
mode==='video' ? '<video class="content-media" width="120" height="80" muted autoplay playsinline></video>' : '';
|
||
let rows=row('a',body);
|
||
if(mode==='cropped-lazy') rows='<div style="height:100px"></div>'+rows;
|
||
if(mode==='offscreen-lazy') rows=row('first',text)+'<div style="height:180px"></div>'+row('a',image());
|
||
response.setHeader('Content-Type','text/html; charset=utf-8');
|
||
response.end(`<!doctype html><html><head><title>显式评论内容类型</title><style>body{margin:10px}#content-window{width:640px;height:160px;overflow:auto}.content-row{height:130px}.content-author{display:block;height:20px}.content-root{min-height:10px}.content-text{display:block;height:20px}.content-media{display:inline-block;width:100px;height:80px;vertical-align:top}</style></head><body><section id="content-container"><div id="content-window">${rows}</div></section>${mode==='video' ? `<script>const canvas=document.createElement('canvas');canvas.width=120;canvas.height=80;const ctx=canvas.getContext('2d');ctx.fillStyle='#468';ctx.fillRect(0,0,120,80);const video=document.querySelector('video');video.srcObject=canvas.captureStream(2);video.play().catch(()=>{});setInterval(()=>ctx.fillRect(0,0,120,80),100)</script>` : ''}</body></html>`);
|
||
return true;
|
||
}
|
||
|
||
function checkTiming(result,steps){
|
||
assert.deepEqual(Object.keys(result.verify_timing).sort(),['duration_ms','entry_ms','close_ms'].sort());
|
||
assert.equal(result.verify_timing.close_ms,null);
|
||
assert.ok(Number.isInteger(result.verify_timing.duration_ms) && result.verify_timing.duration_ms>=0);
|
||
assert.ok(Number.isInteger(result.verify_timing.entry_ms) && result.verify_timing.entry_ms>=0);
|
||
assert.equal(result.step_timings.length,steps.length);
|
||
for(const [index,item] of result.step_timings.entries()){
|
||
assert.deepEqual(Object.keys(item).sort(),['step_position','kind','status','duration_ms','phase_ms'].sort());
|
||
assert.equal(item.step_position,index+1);assert.equal(item.kind,steps[index].kind);
|
||
assert.ok(Object.keys(item.phase_ms).every(key=>phases.includes(key)));assert.ok(Object.hasOwn(item.phase_ms,'validate'));
|
||
assert.equal(Object.hasOwn(item.phase_ms,'capture_before'),false,'verify reuses prior evidence rather than timing an unexecuted screenshot');
|
||
assert.ok(Object.values(item.phase_ms).every(value=>Number.isInteger(value) && value>=0));
|
||
assert.equal(Object.values(item.phase_ms).reduce((sum,value)=>sum+value,0),item.duration_ms,'phase durations are exclusive');
|
||
assert.ok(item.duration_ms<=result.verify_timing.duration_ms+1);
|
||
}
|
||
}
|
||
|
||
export async function contentRegression(client,entryUrl,passed){
|
||
const task='explicit-comment-content',base=new URL('/fixture/content-comments',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 fields=[{name:'comment_id',source:'attribute',attribute:'id',required:true},
|
||
{name:'author',selector:'.content-author',source:'text',required:true},
|
||
{name:'body',selector:'.content-root',source:'content',text_selector:'.content-text',media_selector:'.content-media',max_media:2,required:true}];
|
||
const step={kind:'extract',selector:'#content-container',row_selector:'.content-row',complete_only:true,fields,limit:5,output_name:'comments'};
|
||
const run=(mode,extra={})=>client.call('verify',{task_id:task,entry_url:base+'?mode='+mode,parameters:{},steps:[step],expected_outputs:{comments:{state:'nonempty'}},...extra});
|
||
for(const mode of ['text','media','mixed','video']){
|
||
const result=await run(mode);assert.equal(result.passed,true,mode+': '+JSON.stringify(result.errors));
|
||
const body=result.outputs.comments.items[0].body;
|
||
assert.equal(body.kind,mode==='video' ? 'media' : mode);assert.equal(validContentValue(fields[2],body),true);
|
||
assert.equal(body.text===null,['media','video'].includes(mode));assert.equal(body.media.length,mode==='text' ? 0 : 1);
|
||
if(body.media.length) assert.equal(body.media[0].kind,mode==='video' ? 'video' : 'image');
|
||
assert.equal(validNonemptyExtractOutput(step,result.outputs.comments),true);checkTiming(result,[step]);
|
||
assert.equal(result.step_timings[0].status,'passed');
|
||
assert.ok(!JSON.stringify(result).includes('RAW-MEDIA-SECRET'),'media source URLs are never returned');
|
||
}
|
||
passed('显式content合同从真实可见DOM区分文字/图片/混合/已加载video;正文仅真实text,媒体仅相对CSS且无src/OCR');
|
||
const capture=await client.call('capture',{task_id:task});
|
||
const hints=capture.elements.flatMap(element=>element.read_hints || []).filter(hint=>hint.source==='media');
|
||
assert.ok(hints.some(hint=>hint.media_kind==='video' && hint.loaded===true));
|
||
assert.ok(capture.elements.every(element=>(element.read_hints || []).length<=12));
|
||
const capped=await run('many');assert.equal(capped.passed,true,JSON.stringify(capped.errors));
|
||
assert.equal(capped.outputs.comments.items[0].body.media.length,2);assert.equal(capped.outputs.comments.items[0].body.media_truncated,true);assert.equal(capped.outputs.comments.truncated,true);
|
||
const corrupt={...capped.outputs.comments,truncated:false};assert.equal(validNonemptyExtractOutput(step,corrupt),false);
|
||
const textAssertion=await run('media',{expected_outputs:{comments:'image'}});assert.equal(textAssertion.passed,false,'kind cannot satisfy ordinary content assertions');
|
||
checkTiming(textAssertion,[step]);assert.equal(textAssertion.step_timings[0].status,'passed','a later output assertion does not rewrite action timing');
|
||
const cssAssertion=await run('media',{expected_outputs:{comments:'img'}});assert.equal(cssAssertion.passed,false,'media CSS cannot satisfy ordinary content assertions');
|
||
passed('有界媒体hint可学习相对CSS与加载事实;max_media截断传播到顶层,类型/定位字符串不能冒充文字断言');
|
||
for(const [mode,code] of [['empty','REQUIRED_FIELD_MISSING'],['loading','CONTENT_MEDIA_NOT_READY'],['hidden','REQUIRED_FIELD_MISSING'],['avatar','UNSAFE_CONTENT_MEDIA'],['sensitive','SENSITIVE_OUTPUT'],['multiple','NON_UNIQUE_FIELD']]){
|
||
const result=await run(mode);assert.equal(result.passed,false,mode);assert.deepEqual(result.error_codes,[code],JSON.stringify(result.errors));
|
||
assert.equal(result.outputs.comments,undefined);checkTiming(result,[step]);assert.equal(result.step_timings[0].status,'failed');
|
||
assert.ok(result.step_timings[0].phase_ms.failure_capture>0);
|
||
}
|
||
const old=await run('media',{steps:[{...step,fields:fields.map(field=>field.name==='body' ? {name:'body',selector:'.content-text',source:'text',required:true} : field)}]});
|
||
assert.equal(old.passed,false);assert.deepEqual(old.error_codes,['REQUIRED_FIELD_MISSING']);
|
||
passed('空/未加载/隐藏/头像/敏感/多文本候选明确失败且保真实failed时序;旧文字required仍不接受纯媒体');
|
||
const cropped=await run('cropped-lazy');assert.equal(cropped.passed,false);assert.deepEqual(cropped.error_codes,[],'clipped lazy media is skipped rather than misreported as missing');
|
||
assert.equal(cropped.outputs.comments.count,0);assert.equal(cropped.outputs.comments.skipped_incomplete_count,1);
|
||
const offscreen=await run('offscreen-lazy');assert.equal(offscreen.passed,true,JSON.stringify(offscreen.errors));assert.equal(offscreen.outputs.comments.count,1);assert.equal(offscreen.outputs.comments.skipped_incomplete_count,0);
|
||
const realMissing=await run('cropped-lazy',{steps:[{...step,fields:[...fields,{name:'later',selector:'.truly-missing',source:'text',required:true}]}]});
|
||
assert.deepEqual(realMissing.error_codes,['REQUIRED_FIELD_MISSING'],'skipping a clipped content field must not hide a later missing required field');
|
||
passed('complete_only裁剪lazy媒体只记跳过、完全离屏沿旧范围忽略;完整可见未加载失败,裁剪不掩盖后续真实缺失');
|
||
const batchStep={...step,batch:{scroll_selector:'#content-window',pixels:130,max_batches:1,dedupe_field:'comment_id'}};
|
||
const batched=await run('text',{steps:[batchStep]});assert.equal(batched.passed,true,JSON.stringify(batched.errors));assert.ok(batched.step_timings[0].phase_ms.batch_capture>0);
|
||
assert.equal(batched.step_timings[0].phase_ms.execute+batched.step_timings[0].phase_ms.batch_capture<=batched.step_timings[0].duration_ms,true);
|
||
for(const patch of [
|
||
{required:false},{max_media:0},{max_media:5},{text_selector:'body'},{media_selector:'javascript:alert(1)'},{attribute:'src'},
|
||
]) assert.throws(()=>validateAction({...step,fields:[...fields.slice(0,2),{...fields[2],...patch}]}),error=>error.code==='INVALID_INPUT');
|
||
assert.throws(()=>validateAction({...step,fields:[{name:'body',source:'text',required:true,media_selector:'img'}]}),error=>error.code==='INVALID_INPUT');
|
||
await client.call('close',{task_id:task});
|
||
passed('批次截图单独独占计时,10phase合计真实step耗时;媒体配置严格有界且不能混入旧字段源');
|
||
}
|