assistant/tools/learning/test-content-scenarios.mjs

101 lines
10 KiB
JavaScript
Raw 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 {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耗时;媒体配置严格有界且不能混入旧字段源');
}