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('');return true; } if(url.pathname!=='/fixture/content-comments') return false; const mode=url.searchParams.get('mode') || 'text'; const image=(extra='')=>``; const row=(id,body)=>`
合成作者
${body}
`; const text='真实文字,装修需求'; 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' ? ''+image()+'' : mode==='sensitive' ? '
'+image()+'
' : mode==='multiple' ? text+text : mode==='many' ? Array.from({length:5},()=>image()).join('') : mode==='video' ? '' : ''; let rows=row('a',body); if(mode==='cropped-lazy') rows='
'+rows; if(mode==='offscreen-lazy') rows=row('first',text)+'
'+row('a',image()); response.setHeader('Content-Type','text/html; charset=utf-8'); response.end(`显式评论内容类型
${rows}
${mode==='video' ? `` : ''}`); 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耗时;媒体配置严格有界且不能混入旧字段源'); }