assistant/tools/learning/test-browser-worker.mjs

1351 lines
112 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 { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import readline from 'node:readline';
import { fileURLToPath } from 'node:url';
import { redactUrl, validNonemptyExtractOutput, withVerifyDeadline, snapshotSeekResults } from './browser-worker.mjs';
import { validateAction } from './action-contract.mjs';
import {contentFixture,contentRegression} from './test-content-scenarios.mjs';
import {metadataFixture,metadataRegression} from './test-target-metadata.mjs';
import {viewportFixture,viewportRegression} from './test-viewport-seek.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TEMP_ROOT = path.join(ROOT, '.tmp');
const CHANNEL = process.env.ASSISTANT_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : 'chromium');
const KEEP_ARTIFACTS = process.argv.includes('--keep-artifacts');
const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[char]));
function browserClient(home) {
const child = spawn(process.execPath, [path.join(ROOT, 'tools/learning/browser-worker.mjs')], {
cwd: ROOT,
env: {
...process.env,
ASSISTANT_LEARNING_HOME: home,
ASSISTANT_BROWSER_CHANNEL: CHANNEL,
ASSISTANT_BROWSER_HEADLESS: '1',
},
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
let sequence = 0;
let stderr = '';
let exitResult;
let resolveExit;
const exited = new Promise(resolve => { resolveExit = resolve; });
const requests = new Map();
const output = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
output.on('line', line => {
let reply;
try { reply = JSON.parse(line); }
catch {
const error = new Error('Worker stdout contains a non-JSON protocol line');
for (const request of requests.values()) { clearTimeout(request.timer); request.reject(error); }
requests.clear();
return;
}
const request = requests.get(reply.id);
if (!request) return;
requests.delete(reply.id);
clearTimeout(request.timer);
if (reply.error) {
const error = new Error(reply.error.message);
error.code = reply.error.code;
error.before = reply.error.before;
error.after = reply.error.after;
error.diagnostics = reply.error.diagnostics;
error.batch_evidence=reply.error.batch_evidence;
request.reject(error);
} else request.resolve(reply.result);
});
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-12_000); });
child.on('error', error => {
for (const request of requests.values()) { clearTimeout(request.timer); request.reject(error); }
requests.clear();
});
child.on('exit', (code, signal) => {
exitResult = { code, signal };
for (const request of requests.values()) {
clearTimeout(request.timer);
request.reject(new Error(`Worker exited before replying, code=${code}. ${stderr}`));
}
requests.clear();
resolveExit(exitResult);
});
return {
call(method, params = {}) {
const id = `worker-test-${++sequence}`;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
requests.delete(id);
reject(new Error(`Worker timed out: ${method}. ${stderr}`));
}, 100_000);
requests.set(id, { resolve, reject, timer });
child.stdin.write(JSON.stringify({ id, method, params }) + '\n', error => {
if (!error) return;
clearTimeout(timer);
requests.delete(id);
reject(error);
});
});
},
async close({ timeout = 15_000 } = {}) {
if (exitResult) return exitResult;
if (!child.stdin.writableEnded) child.stdin.end();
let timer;
try {
return await Promise.race([exited, new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('Worker did not release browsers after stdin EOF')), timeout);
})]);
} catch (error) {
child.kill('SIGTERM');
throw error;
} finally {
clearTimeout(timer);
output.close();
}
},
};
}
async function seekRegression(client,entryUrl,passed,{bindingNoteId,bindingTitle,detailRequests}) {
const task='bounded-seek-regression',base=new URL('/fixture/seek-target',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 seek={kind:'scroll',selector:'#seek-window',direction:'down',pixels:160,max_scrolls:5,timeout_ms:2000,
until_target:{selector:'#seek-global a.seek-title',field:'text',value:'目标笔记'}};
const run=(mode,extra={})=>client.call('verify',{task_id:task,entry_url:base+'?mode='+mode,parameters:{},
steps:[{...seek,...extra},{kind:'read',selector:'#seek-status',output_name:'status'}],expected_outputs:{status:'实际定位样本'}});
const actual=await run('offscreen');
assert.equal(actual.passed,true,JSON.stringify(actual.errors));
assert.equal(actual.seek_results.length,1);
const summary=actual.seek_results[0];
assert.deepEqual(Object.keys(summary).sort(),['step_position','scrolls','movements','target_reached','stop_reason'].sort());
assert.ok(summary.scrolls>0 && summary.scrolls<=5);
assert.equal(summary.scrolls,summary.movements.length);
assert.equal(summary.target_reached,true);assert.equal(summary.stop_reason,'target_reached');
assert.ok(summary.movements.every(move=>Object.keys(move).length===4 && move.after>move.before));
assert.equal(actual.verify_budget_ms,undefined,'unbound verify must not get the bound-eight-step budget extension');
const visible=await run('visible');
assert.equal(visible.passed,true,JSON.stringify(visible.errors));assert.equal(visible.seek_results[0].scrolls,0);
assert.deepEqual(visible.seek_results[0].movements,[]);
passed('固定容器内全路径CSS只匹配后代,重排离屏目标有限位移到完整可操作;初始可见0次且旧无绑定预算不扩大');
for(const [mode,code,reason,extra] of [
['missing','SCROLL_BUDGET_EXHAUSTED','scroll_budget',{max_scrolls:1}],
['missing','SCROLL_BOUNDARY_BEFORE_TARGET','boundary',{pixels:1600,max_scrolls:3}],
['time','SCROLL_TARGET_TIMEOUT','time_budget',{timeout_ms:1}],
['duplicate','NON_UNIQUE_TARGET','ambiguous',{}],
['obscured','TARGET_OBSCURED','obscured',{}],
['source','SCROLL_SOURCE_CHANGED','source_changed',{}],
['root','STALE_TARGET','target_changed',{}],
['blocked','WRITE_REQUEST_BLOCKED','blocked_request',{}],
]){
const failure=await run(mode,extra);
assert.equal(failure.passed,false,mode);
assert.deepEqual(failure.error_codes,[code],mode+': '+JSON.stringify(failure.errors));
assert.equal(failure.seek_results[0].stop_reason,reason,mode);
assert.equal(failure.seek_results[0].target_reached,false,mode);
assert.equal(failure.seek_results[0].scrolls,failure.seek_results[0].movements.length,mode);
assert.ok(failure.evidence.length>=3,'failed seek retains real before/after captures');
if(['source','root','blocked','missing'].includes(mode)) assert.ok(failure.seek_results[0].scrolls>0,mode+' must retain actual movement');
assert.equal(failure.outputs.status,undefined,'failed seek must not continue reading');
}
passed('目标缺失的滚动/边界/时间预算、重名、遮挡、来源变化、根替换及未知写请求明确停止并保留真实有界位移与失败图');
const lazy=await run('lazy',{pixels:160,max_scrolls:1,timeout_ms:1500});
// A missing target may populate naturally while the fixed bounded final poll waits.
assert.equal(lazy.passed,true,JSON.stringify(lazy.errors));
assert.equal(lazy.seek_results[0].scrolls,1);
passed('暂不存在的目标允许在固定短等中自然出现,未重放滚动或主动导航');
const unsafe=await run('unsafe',{until_target:{...seek.until_target,value:'发送'}});
assert.equal(unsafe.passed,false);assert.deepEqual(unsafe.error_codes,['WRITE_ACTION_BLOCKED']);
for(const patch of [
{until_selector:'.seek-title'}, {selector:'body'}, {timeout_ms:0}, {timeout_ms:15001},
{until_target:{...seek.until_target,parameter:'title'}}, {until_target:{...seek.until_target,field:'attribute',attribute:'token'}},
{until_target:{...seek.until_target,selector:'body a'}}, {until_target:{...seek.until_target,extra:true}},
]) assert.throws(()=>validateAction({...seek,...patch}),error=>error.code==='INVALID_INPUT');
assert.throws(()=>validateAction({kind:'scroll',selector:'#seek-window',direction:'down',pixels:100,max_scrolls:1,timeout_ms:10}),error=>error.code==='INVALID_INPUT');
validateAction({...seek,scroll_scope:'viewport'});
for(const patch of [{scroll_scope:'container'},{scroll_scope:null},{scroll_scope:'viewport',until_target:undefined},{scroll_scope:'viewport',kind:'read'},{selector:'html'},{selector:'HTML'}]) {
assert.throws(()=>validateAction({...seek,...patch}),error=>error.code==='INVALID_INPUT');
}
passed('目标定位不触发发送/危险动作,合同拒绝混用目标、body根、越界时间、双参数、敏感属性及扩展字段');
await client.call('close',{task_id:task});
const bindingTask='bound-eight-seek',bindingEntry=new URL('/fixture/note-binding',entryUrl).href;
const steps=[
{kind:'fill',selector:'#binding-search',parameter:'keyword'},
{kind:'press',selector:'#binding-search',key:'Enter'},
{kind:'wait',selector:'#binding-results a.binding-title',state:'visible',min_count:1,timeout_ms:1500},
{kind:'scroll',selector:'#binding-results',direction:'down',pixels:160,max_scrolls:5,timeout_ms:2500,until_target:{selector:'#binding-results a.binding-title',field:'text',parameter:'note_title'}},
{kind:'click',selector:'#binding-results a.binding-title',target:{field:'text',parameter:'note_title'}},
{kind:'wait',selector:'#binding-detail',state:'visible',timeout_ms:1500},
{kind:'extract',selector:'#binding-detail',output_name:'note_detail',limit:1,fields:[
{name:'title',selector:'.detail-title',source:'text',required:true},{name:'body',selector:'.detail-body',source:'text',required:true},{name:'author',selector:'.detail-author',source:'text',required:true}]},
{kind:'extract',selector:'#binding-comments-root',row_selector:'.binding-comment',output_name:'comments',limit:3,complete_only:true,fields:[
{name:'comment_id',source:'attribute',attribute:'id',required:true},{name:'author',selector:'.comment-author',source:'text',required:true},{name:'body',selector:'.comment-body',source:'text',required:true}],
batch:{scroll_selector:'#binding-scroller',pixels:80,max_batches:2,dedupe_field:'comment_id'}},
];
await client.call('open',{task_id:bindingTask,account_key:'test-account',entry_url:bindingEntry});
await client.call('ready',{task_id:bindingTask,identity_label:'隔离八步绑定'});
const runBound=(mode,extra={})=>client.call('verify',{task_id:bindingTask,entry_url:bindingEntry+'?mode='+mode,steps,
parameters:{keyword:'装修',note_title:bindingTitle},note_binding:{note_id:bindingNoteId},expected_outputs:{note_detail:{state:'nonempty'},comments:{state:'nonempty'}},...extra});
const bound=await runBound('seek');
assert.equal(bound.passed,true,JSON.stringify(bound.errors));
assert.equal(bound.verify_budget_ms,92500);assert.equal(bound.seek_results[0].step_position,4);
assert.ok(bound.seek_results[0].scrolls>0);assert.equal(bound.note_binding_result.href_checked,true);assert.equal(bound.note_binding_result.url_checked,true);
assert.equal(Object.keys(bound.note_binding_result).length,5);assert.ok(bound.outputs.comments.count>0);
const priorRequests=detailRequests();
const wrong=await runBound('seek-wrong-id');
assert.equal(wrong.passed,false);assert.deepEqual(wrong.error_codes,['NOTE_BINDING_MISMATCH']);
assert.equal(wrong.seek_results[0].stop_reason,'binding_mismatch');assert.equal(wrong.seek_results[0].scrolls,0);
assert.equal(detailRequests(),priorRequests);assert.deepEqual(wrong.outputs,{});
passed('新八步定位与点击同CSS/标题参数,定位及点击分别核对可信ID;同标题错误ID0次滚动0次打开,proof旧5键且仅新绑定链预算增加');
const viewportSteps=steps.map((step,index)=>index===3 ? {...step,scroll_scope:'viewport'} : step);
const viewportBound=await runBound('seek-viewport',{steps:viewportSteps});
assert.equal(viewportBound.passed,true,JSON.stringify(viewportBound.errors));
assert.ok(viewportBound.seek_results[0].scrolls>0);assert.equal(viewportBound.verify_budget_ms,92500);
assert.ok(viewportBound.seek_results[0].movements.every(move=>move.client_height>180 && move.after>move.before));
assert.equal(viewportBound.note_binding_result.href_checked,true);assert.equal(viewportBound.note_binding_result.url_checked,true);
assert.equal(Object.keys(viewportBound.note_binding_result).length,5);assert.ok(viewportBound.outputs.comments.count>0);
const priorViewportRequests=detailRequests();
const viewportWrong=await runBound('seek-viewport-wrong-id',{steps:viewportSteps});
assert.equal(viewportWrong.passed,false);assert.deepEqual(viewportWrong.error_codes,['NOTE_BINDING_MISMATCH']);
assert.equal(viewportWrong.seek_results[0].scrolls,0);assert.equal(detailRequests(),priorViewportRequests);
passed('显式viewport八步在普通固定根内查找并实际滚页面,保留旧proof/预算;错误笔记ID零滚动零打开');
for(const patch of [
{steps:steps.map((step,index)=>index===3 ? {...step,until_target:{...step.until_target,selector:'a.binding-title'}} : step)},
{steps:steps.map((step,index)=>index===3 ? {...step,until_target:{...step.until_target,parameter:'keyword'}} : step)},
]) await assert.rejects(()=>runBound('seek',patch),error=>error.code==='NOTE_BINDING_CONTRACT');
await client.call('close',{task_id:bindingTask});
passed('八步绑定必须定位与后续点击的CSS和标题参数精确一致,不能套用其它滚动/点击合同');
}
async function budgetRegression(entryUrl,home,passed,{bindingNoteId,bindingTitle}) {
let cleaned=0;
assert.equal(await withVerifyDeadline(async()=>17,{budgetMs:100,cleanupReserveMs:10,onTimeout:()=>{cleaned+=1;}}),17);
assert.equal(cleaned,0);
let rejectPending;
await assert.rejects(()=>withVerifyDeadline(()=>new Promise((_,reject)=>{rejectPending=reject;}),{
budgetMs:50,cleanupReserveMs:10,onTimeout:()=>{cleaned+=1;rejectPending(new Error('cancelled browser operation'));},
}),error=>error.code==='VERIFY_BUDGET');
assert.equal(cleaned,1,'cancellation rejection must not outrun the budget error or run cleanup twice');
await assert.rejects(()=>withVerifyDeadline(async control=>{await new Promise(resolve=>setTimeout(resolve,1));control.check();},{
budgetMs:100,startedMs:Date.now()-200,cleanupReserveMs:10,onTimeout:()=>{cleaned+=1;},
}),error=>error.code==='VERIFY_BUDGET');
assert.equal(cleaned,2);
const active=[{step_position:4,scrolls:1,movements:[{before:0,after:160,scroll_height:1000,client_height:180}],target_reached:false,stop_reason:'time_budget'}];
const saved=snapshotSeekResults(active),savedJson=JSON.stringify(saved);
active[0].movements[0].after=320;
active[0].movements.push({before:320,after:480,scroll_height:1000,client_height:180});
active[0].target_reached=true;active.push({...active[0],step_position:5});
assert.equal(JSON.stringify(saved),savedJson,'a late browser response cannot rewrite a returned seek summary or nested movement');
passed('总截止计时覆盖未返回的单步,取消异常不能抢先丢失VERIFY_BUDGET,正常完成不误关且预算到期只清理一次');
const program=`
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import {dispatch,verify} from ${JSON.stringify(new URL('./browser-worker.mjs',import.meta.url).href)};
const origin=${JSON.stringify(new URL(entryUrl).origin)},noteId=${JSON.stringify(bindingNoteId)},noteTitle=${JSON.stringify(bindingTitle)};
const basic=origin+'/fixture/budget-basic';
let serial=0;const results=[];
const open=async(url)=>{const task='budget-'+(++serial);await dispatch('open',{task_id:task,account_key:'budget-isolated',entry_url:url});await dispatch('ready',{task_id:task,identity_label:'隔离总预算测试'});return task;};
const read={kind:'read',selector:'#budget-status',output_name:'detail'};
const check=async(result,task,started)=>{
assert.equal(result.passed,false);assert.ok(result.error_codes.includes('VERIFY_BUDGET'),JSON.stringify(result.errors));assert.equal(result.session_closed,true);
assert.ok(Date.now()-started<8500,'short internal deadline must return before an ordinary 10s screenshot or 15s wait');
assert.equal((await dispatch('status',{task_id:task})).status,'closed');
assert.ok(result.step_timings.length>0);assert.equal(result.step_timings.at(-1).status,'interrupted');
assert.ok(result.step_timings.slice(0,-1).every(step=>step.status==='passed'));
assert.ok(result.step_timings.every(step=>Object.values(step.phase_ms).reduce((sum,value)=>sum+value,0)===step.duration_ms));
assert.ok(Number.isInteger(result.verify_timing.close_ms) && result.verify_timing.close_ms>=0);
assert.ok(result.evidence.length>=2,'already completed real screenshots are retained');
for(const capture of result.evidence){const png=await fs.readFile(capture.screenshot_path);assert.equal(png.subarray(1,4).toString(),'PNG');}
const saved=JSON.stringify(result);await new Promise(resolve=>setTimeout(resolve,250));assert.equal(JSON.stringify(result),saved,'no late operation may append a successful output or evidence');
};
for(const mode of ['wait','font','dom','viewport-dom']){
const task=await open(basic+'?mode='+mode);
const tail=mode==='wait' ? {kind:'wait',selector:'#budget-pending',state:'text',text:'永不出现',timeout_ms:15000} : mode==='font' ? {kind:'click',selector:'#budget-font-button'} :
{kind:'scroll',selector:'#budget-seek',...(mode==='viewport-dom' ? {scroll_scope:'viewport'} : {}),direction:'down',pixels:160,max_scrolls:10,timeout_ms:15000,until_target:{selector:'a.budget-target',field:'text',value:'目标笔记'}};
const started=Date.now();const result=await verify({task_id:task,entry_url:basic+'?mode='+mode,parameters:{},steps:[read,tail,{kind:'read',selector:'#budget-reuse',output_name:'late'}],expected_outputs:{detail:'已经读取的真实内容'}},{budgetMs:5000,cleanupReserveMs:500});
await check(result,task,started);assert.ok(result.outputs.detail[0].text.includes('已经读取'));assert.equal(result.outputs.late,undefined);
if(['dom','viewport-dom'].includes(mode)){assert.equal(result.seek_results[0].stop_reason,'time_budget');assert.equal(result.seek_results[0].target_reached,false);assert.ok(result.seek_results[0].scrolls>0);assert.equal(result.seek_results[0].scrolls,result.seek_results[0].movements.length);}
results.push({mode,evidence:result.evidence.length,elapsed_ms:Date.now()-started});
}
const reopened=await open(basic);const retained=await dispatch('capture',{task_id:reopened});assert.ok(retained.text.includes('retained'));await dispatch('close',{task_id:reopened});
const entryTask=await open(origin+'/fixture/budget-entry');
const entryTimeout=await verify({task_id:entryTask,entry_url:origin+'/fixture/budget-entry',parameters:{},steps:[read],expected_outputs:{detail:'已经读取的真实内容'}},{budgetMs:2500,cleanupReserveMs:500});
assert.deepEqual(entryTimeout.error_codes,['VERIFY_BUDGET']);assert.equal(entryTimeout.session_closed,true);assert.deepEqual(entryTimeout.step_timings,[]);assert.equal(Object.keys(entryTimeout.outputs).length,0);assert.equal(entryTimeout.evidence.length,0,'unfinished entry capture must not be invented');
assert.ok(Number.isInteger(entryTimeout.verify_timing.close_ms));assert.ok(entryTimeout.verify_timing.entry_ms+entryTimeout.verify_timing.close_ms<=entryTimeout.verify_timing.duration_ms+10,'entry wall time excludes subsequent closing time');
results.push({mode:'entry-timeout',timing:entryTimeout.verify_timing});
const entry=origin+'/fixture/budget-binding',task=await open(entry);
const steps=[{kind:'fill',selector:'#binding-search',parameter:'keyword'},{kind:'press',selector:'#binding-search',key:'Enter'},
{kind:'wait',selector:'#binding-results a.binding-title',state:'visible',min_count:1,timeout_ms:1500},
{kind:'scroll',selector:'#binding-results',direction:'down',pixels:160,max_scrolls:5,timeout_ms:2500,until_target:{selector:'#binding-results a.binding-title',field:'text',parameter:'note_title'}},
{kind:'click',selector:'#binding-results a.binding-title',target:{field:'text',parameter:'note_title'}},{kind:'wait',selector:'#binding-detail',state:'visible',timeout_ms:1500},
{kind:'extract',selector:'#binding-detail',output_name:'note_detail',limit:1,fields:[{name:'title',selector:'.detail-title',source:'text',required:true},{name:'body',selector:'.detail-body',source:'text',required:true},{name:'author',selector:'.detail-author',source:'text',required:true}]},
{kind:'extract',selector:'#binding-comments-root',row_selector:'.binding-comment',output_name:'comments',limit:3,complete_only:true,fields:[{name:'comment_id',source:'attribute',attribute:'id',required:true},{name:'author',selector:'.comment-author',source:'text',required:true},{name:'body',source:'content',text_selector:'.comment-body',media_selector:'img.comment-image',required:true}],batch:{scroll_selector:'#binding-scroller',pixels:80,max_batches:2,dedupe_field:'comment_id'}}];
const started=Date.now();const bound=await verify({task_id:task,entry_url:entry,steps,parameters:{keyword:'装修',note_title:noteTitle},note_binding:{note_id:noteId},expected_outputs:{note_detail:{state:'nonempty'},comments:{state:'nonempty'}}},{budgetMs:7000,cleanupReserveMs:500});
await check(bound,task,started);assert.equal(bound.verify_budget_ms,92500);assert.equal(bound.outputs.note_detail.items[0].title,noteTitle);assert.equal(bound.outputs.comments,undefined);assert.equal(bound.seek_results[0].target_reached,true);assert.equal(bound.seek_results[0].stop_reason,'target_reached');assert.ok(bound.evidence.length>=9,'completed first batch and current real before capture are retained');assert.equal(bound.note_binding_result.href_checked,true);assert.equal(bound.note_binding_result.url_checked,true);
results.push({mode:'bound-comments',evidence:bound.evidence.length,elapsed_ms:Date.now()-started});
console.log(JSON.stringify({results,profile_preserved:true,no_late_success:true}));
`;
const child=spawn(process.execPath,['--input-type=module','-e',program],{cwd:ROOT,env:{...process.env,ASSISTANT_LEARNING_HOME:path.join(home,'budget-isolated'),ASSISTANT_BROWSER_CHANNEL:CHANNEL,ASSISTANT_BROWSER_HEADLESS:'1'},windowsHide:true,stdio:['ignore','pipe','pipe']});
let stdout='',stderr='';child.stdout.on('data',chunk=>{stdout+=chunk;});child.stderr.on('data',chunk=>{stderr+=chunk;});
const result=await new Promise((resolve,reject)=>{const timer=setTimeout(()=>{child.kill('SIGTERM');reject(new Error('Isolated budget regression did not terminate'));},45000);child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('exit',(code,signal)=>{clearTimeout(timer);resolve({code,signal});});});
assert.equal(result.code,0,stderr);const report=JSON.parse(stdout.trim());
await fs.writeFile(path.join(home,'budget-isolated-report.json'),JSON.stringify(report,null,2));
passed('真实Edge总截止中断等待/截图/容器与viewport阻塞DOM和入口取证,入口不重算关闭时间;已得PNG/时序留存、会话关闭而profile可重开',report);
passed('详情与首批完成后评论截图超时保留真实partial/proof/批次图,已完成seek维持true且不迟到写成功');
}
async function main() {
await fs.mkdir(TEMP_ROOT, { recursive: true });
const runRoot = await fs.mkdtemp(path.join(TEMP_ROOT, 'worker-regression-'));
const home = path.join(runRoot, 'learning');
const results = [];
let succeeded = false;
let slowRequestCount = 0;
let baselineRequestCount = 0;
let blockedWriteReachedServer = 0;
let boundDetailRequests=0;
const bindingNoteId='a'.repeat(24),bindingOtherId='b'.repeat(24),bindingPhoneId='abcdef13800138000abcde12';
const bindingTitle='装修准备:合成选中笔记';
const semanticChanges = new Map();
let resolveSlowNavigation;
const slowNavigation = new Promise(resolve => { resolveSlowNavigation = resolve; });
const server = createServer((request, response) => {
const url = new URL(request.url, 'http://127.0.0.1');
if(contentFixture(url,request,response)) return;
if(metadataFixture(url,request,response)) return;
if(viewportFixture(url,request,response)) return;
if(url.pathname==='/fixture/budget-font') return; // A real screenshot font wait, interrupted by closing its isolated context.
const stallFont=`const font=new FontFace('budget-hang','url(/fixture/budget-font)');document.fonts.add(font);font.load().catch(()=>{});const span=document.createElement('span');span.textContent='等待字体';span.style.fontFamily='budget-hang';document.body.append(span);`;
if(url.pathname==='/fixture/budget-entry'){
response.setHeader('Content-Type','text/html; charset=utf-8');response.end(`<!doctype html><html><body><p id="budget-status">已经读取的真实内容</p><script>${stallFont}</script></body></html>`);return;
}
if(url.pathname==='/fixture/budget-basic'){
response.setHeader('Content-Type','text/html; charset=utf-8');
response.setHeader('Set-Cookie','budget-session=retained; Max-Age=86400; Path=/; SameSite=Lax');
const viewportBudget=url.searchParams.get('mode')==='viewport-dom';
response.end(`<!doctype html><html><head><title>总预算隔离样本</title></head><body><p id="budget-status">已经读取的真实内容</p><p id="budget-reuse">${request.headers.cookie?.includes('budget-session=retained') ? 'retained' : 'fresh'}</p><button id="budget-font-button" onclick="${stallFont}">加载</button><p id="budget-pending">等待文本</p><div id="budget-seek" style="${viewportBudget ? 'width:500px' : 'height:180px;width:500px;overflow:auto'}"><div style="height:${viewportBudget ? 1200 : 500}px"></div><a class="budget-target" href="/fixture/budget-basic">目标笔记</a><div style="height:500px"></div></div><script>${viewportBudget ? 'window' : "document.querySelector('#budget-seek')"}.addEventListener('scroll',()=>{${['dom','viewport-dom'].includes(url.searchParams.get('mode')) ? 'const end=Date.now()+10000;while(Date.now()<end){}' : ''}},{once:true})</script></body></html>`);
return;
}
if(url.pathname==='/fixture/budget-binding'){
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>预算绑定样本</title></head><body><form method="get"><input id="binding-search" type="search" name="keyword" aria-label="搜索笔记"><button>搜索</button></form><section id="binding-results" style="height:180px;width:500px;overflow:auto"><a class="binding-title" href="/explore/${bindingNoteId}?mode=budget-comments">${bindingTitle}</a></section></body></html>`);
return;
}
if(url.pathname==='/fixture/note-binding'){
const mode=url.searchParams.get('mode') || 'normal',id=mode==='phone' ? bindingPhoneId : mode==='wrong-href' ? bindingOtherId : bindingNoteId;
response.setHeader('Content-Type','text/html; charset=utf-8');
const viewportSeek=mode.startsWith('seek-viewport');
response.end(`<!doctype html><html><head><title>绑定笔记搜索</title><style>${mode.startsWith('seek') ? `${viewportSeek ? '#binding-results{width:500px}' : '#binding-results{height:180px;width:500px;overflow:auto}'}.binding-title{display:block;height:30px}` : ''}</style></head><body><form method="get" action="/fixture/note-binding"><input type="hidden" name="mode" value="${escapeHtml(mode)}"><input id="binding-search" type="search" name="keyword" aria-label="搜索笔记"><button>搜索</button></form><section id="binding-results">${mode.startsWith('seek') ? '<a class="binding-title" href="/explore/'+bindingOtherId+'">可见的其它笔记</a><div style="height:'+(viewportSeek ? 1100 : 500)+'px"></div>' : ''}<a class="binding-title" href="/explore/${['seek-wrong-id','seek-viewport-wrong-id'].includes(mode) ? bindingOtherId : id}?mode=${escapeHtml(mode)}&amp;token=FIXTURE-BINDING-TOKEN#credential=FIXTURE-BINDING-HASH">${bindingTitle}</a></section></body></html>`);
return;
}
if(url.pathname==='/fixture/seek-target'){
const mode=url.searchParams.get('mode') || 'offscreen',title=mode==='unsafe' ? '发送' : '目标笔记';
const anchor=`<a class="seek-title" ${mode==='lazy' ? 'style="position:absolute;top:200px"' : ''} href="/fixture/seek-target?selected=1">${title}</a>`;
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>有界目标定位</title><style>body{margin:10px}#seek-window{height:180px;width:500px;overflow:auto;position:relative}.seek-title{display:block;height:30px;box-sizing:border-box}#seek-cover{position:fixed;left:10px;top:10px;width:500px;height:180px;background:#eee;z-index:10}</style></head><body><section id="seek-global"><div id="seek-window">${mode==='visible' || mode==='obscured' ? '' : '<div style="height:500px">前置内容</div>'}${mode==='missing' || mode==='time' || mode==='lazy' ? '' : anchor}${mode==='duplicate' ? anchor : ''}<div style="height:500px"></div></div><a class="seek-title" href="/fixture/seek-target?outside=1">目标笔记</a></section><p id="seek-status">实际定位样本</p>${mode==='obscured' ? '<div id="seek-cover">遮挡</div>' : ''}<script>const root=document.querySelector('#seek-window');let started=false;root.addEventListener('scroll',()=>{if(started)return;started=true;${mode==='source' ? `history.replaceState({},'',location.pathname+'?changed=1')` : mode==='root' ? `root.replaceWith(root.cloneNode(true))` : mode==='lazy' ? `setTimeout(()=>root.insertAdjacentHTML('afterbegin',${JSON.stringify(anchor)}),250)` : mode==='blocked' ? `fetch('/fixture/write/seek',{method:'POST'}).catch(()=>{})` : ''}})</script></body></html>`);
return;
}
if(/^\/explore\/[a-f0-9]{24}$/.test(url.pathname)){
boundDetailRequests+=1;
const mode=url.searchParams.get('mode') || 'normal';
if(mode==='redirect' && url.pathname.endsWith(bindingNoteId)){
response.writeHead(302,{Location:`/explore/${bindingOtherId}?mode=redirected`}).end();return;
}
response.setHeader('Content-Type','text/html; charset=utf-8');
if(mode==='budget-comments') response.write(`<script>addEventListener('DOMContentLoaded',()=>document.querySelector('#binding-scroller').addEventListener('scroll',()=>{${stallFont}},{once:true}))</script>`);
response.end(`<!doctype html><html><head><title>绑定笔记详情</title><style>body{margin:10px}#binding-scroller{height:110px;width:500px;overflow:auto}.binding-comment{height:80px;box-sizing:border-box}.comment-author{display:block;height:20px}.comment-body{height:30px}</style></head><body><article id="binding-detail"><h1 class="detail-title">${mode==='wrong-title' ? '同ID但标题已变化' : bindingTitle}</h1><p class="detail-body">合成正文,仅用于所选笔记读取测试</p><span class="detail-author">合成笔记作者</span></article><section id="binding-comments-root"><div id="binding-scroller">${['one','two','three'].map(id=>`<div id="binding-comment-${id}" class="binding-comment"><span class="comment-author">评论作者${id}</span><div class="comment-body">合成评论${id}</div></div>`).join('')}</div></section>${mode==='drift-scroll' ? `<script>document.querySelector('#binding-scroller').addEventListener('scroll',()=>history.replaceState({},'', '/explore/${bindingOtherId}?mode=drifted'),{once:true})</script>` : ''}</body></html>`);
return;
}
if (url.pathname === '/fixture/semantic-change') {
const key = url.searchParams.get('case');
semanticChanges.set(key,response);
response.once('close',()=>{ if(semanticChanges.get(key)===response) semanticChanges.delete(key); });
return;
}
if (url.pathname === '/fixture/search-semantics') {
const mode = url.searchParams.get('mode') || 'placeholder';
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>搜索固定语义</title></head><body>
<form method="get"><input id="semantic-search" type="search" name="keyword" aria-label="搜索资料" placeholder="搜索示例0"><button>搜索</button></form>
<input id="plain-field" name="address" aria-label="收货地址" placeholder="地址示例0"><div data-testid="semantic-result">${escapeHtml(url.searchParams.get('keyword') || '初始')} 的结果</div>
<script>const field=document.querySelector('#semantic-search'),plain=document.querySelector('#plain-field');let n=0;setInterval(()=>{field.placeholder='搜索示例'+(++n);plain.placeholder='地址示例'+n},60);
${mode==='placeholder' ? '' : `fetch('/fixture/semantic-change?case=${mode}').then(r=>r.json()).then(()=>{${mode==='aria' ? `field.setAttribute('aria-label','搜索用户')` : mode==='intent' ? `field.name='comment';field.setAttribute('aria-label','回复评论')` : `field.setAttribute('data-testid','new-current-search')`}})`}
</script></body></html>`);
return;
}
if(url.pathname==='/fixture/read-window'){
const mode=url.searchParams.get('mode') || 'base';
const row=(name,id)=>`<div id="${id}" class="comment-item"><span class="author">作者${name}</span><div class="content">评论完整正文${name}</div>${id==='partial-row' ? '<span class="duplicate">一</span><span class="duplicate">二</span><span class="hidden-field" style="display:none">隐藏字段</span><span class="empty"></span><input class="secret-field" type="password" name="password" value="NO-READ">' : ''}</div>`;
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>长文与完整字段</title><style>
body{margin:10px}#read-window{height:150px;width:500px;overflow:auto}.comment-item{height:100px;box-sizing:border-box;position:relative}.author{display:block;height:20px}.content{height:50px}.secret-field{position:absolute;left:2px;top:0;width:1px;height:1px}.empty{display:block;height:10px}
</style></head><body><div id="read-window">${mode==='lazy' ? '<div style="height:200px">长正文</div><div id="lazy-slot" style="height:100px"></div>' : `${mode==='long' ? '<div style="height:560px">长正文</div>' : ''}${row('甲','first-row')}${row('乙','partial-row')}${row('丙','third-row')}`}</div>
${mode==='lazy' ? `<script>const view=document.querySelector('#read-window');let started=false;view.addEventListener('scroll',()=>{if(!started){started=true;setTimeout(()=>document.querySelector('#lazy-slot').innerHTML=${JSON.stringify(row('延迟','lazy-row'))},350)}})</script>` : ''}</body></html>`);
return;
}
if(url.pathname==='/fixture/batch-read'){
const mode=url.searchParams.get('mode') || 'base';
const ids=mode==='fit' ? ['a','b'] : ['boundary','lazy'].includes(mode) ? ['a','b','c'] : ['a','b','c','d','e'];
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>有界评论批次</title><style>body{margin:10px}#batch-container{width:520px}#batch-window{height:180px;overflow:auto;width:500px}.batch-row{height:80px;box-sizing:border-box}.batch-author{display:block;height:20px}.batch-body{height:30px}#other-root{height:60px;overflow:auto;width:500px}.outside-row{height:80px}</style></head><body><section id="batch-container"><div id="batch-window">${ids.map(id=>`<article class="batch-row" data-item-id="${id}"><span class="batch-author">作者${id}</span><div class="batch-body">正文${id}</div></article>`).join('')}</div></section>
<section id="other-root"><article class="outside-row">外部行</article></section>
<div id="titles"><section>无标题首卡</section><section><a class="title" href="/fixture/batch-read?selected=a">标题甲</a></section><section><a class="title" href="/fixture/batch-read?selected=b">标题乙</a></section></div>
<script>const view=document.querySelector('#batch-window');let started=false;view.addEventListener('scroll',()=>{${mode==='conflict' ? `view.querySelector('[data-item-id="b"] .batch-body').textContent='变化后的正文'` : mode==='source' ? `history.replaceState({},'',location.pathname+'?changed=1')` : mode==='root' ? `view.replaceWith(view.cloneNode(true))` : mode==='blocked' ? `fetch('/fixture/write/batch-unknown?token=BATCH-QUERY-SECRET',{method:'POST',body:'BATCH-BODY-SECRET'}).catch(()=>{})` : mode==='lazy' ? `if(!started){started=true;setTimeout(()=>view.querySelector('[data-item-id="c"]').outerHTML='<article class="batch-row" data-item-id="d"><span class="batch-author">作者d</span><div class="batch-body">正文d</div></article>',650)}` : ''}})</script></body></html>`);
return;
}
if(url.pathname==='/fixture/empty-read'){
const mode=url.searchParams.get('mode') || 'base';
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>明确空态</title></head><body><section id="empty-container" style="height:90px;width:500px" ${mode==='busy' ? 'aria-busy="true"' : ''}><p class="empty-state" ${mode==='hidden' ? 'hidden' : ''}>暂无评论</p>${mode==='duplicate' ? '<p class="empty-state">暂无评论</p>' : ''}${mode==='rows' ? '<article class="empty-row" data-item-id="one"><span class="content">真实评论</span></article>' : ''}<span class="whitespace-time" datetime=" ">时间</span></section></body></html>`);
return;
}
if(url.pathname==='/fixture/delayed-collection'){
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>集合延迟加载</title></head><body><section id="delayed-titles">搜索结果加载中</section><script>setTimeout(()=>document.querySelector('#delayed-titles').innerHTML='<section>首卡无标题</section><a class="title" href="/fixture/delayed-collection?a=1">标题甲</a><a class="title" href="/fixture/delayed-collection?a=2">标题乙</a>',2500)</script></body></html>`);
return;
}
if (url.pathname === '/fixture/baseline') {
baselineRequestCount += 1;
response.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
return;
}
if (url.pathname.startsWith('/fixture/write/')) {
blockedWriteReachedServer += 1;
response.writeHead(200).end('unexpected');
return;
}
if (url.pathname === '/fixture/slow') {
slowRequestCount += 1;
// No response headers: verify is now waiting in its real browser goto,
// whose ordinary timeout is 20 seconds. EOF must interrupt it earlier.
resolveSlowNavigation();
return;
}
if (url.pathname === '/fixture/actions') {
const order = url.searchParams.get('order') === 'reverse' ? ['b', 'a'] : ['a', 'b'];
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>受限动作测试</title><style>
body{margin:10px}#scroller{height:160px;overflow:auto;width:500px}.row{height:80px;margin:0;box-sizing:border-box}
#covered{position:relative;width:150px;height:35px}#cover{position:absolute;inset:0;background:#eee;z-index:10}
</style></head><body><main><h1>受限动作样本</h1>
${order.map(id => `<a data-testid="post" data-item-id="${id}" href="/fixture/actions?selected=${id}">笔记 ${id}</a>`).join(' ')}
<div data-testid="selected">当前 ${escapeHtml(url.searchParams.get('selected') || '无')}</div>
<button data-testid="expand" onclick="document.querySelector('#pending').remove();setTimeout(()=>{document.querySelector('#delayed').textContent='加载完成'},350)">展开</button>
<div id="pending" data-testid="pending">等待删除</div><div id="delayed" data-testid="delayed"></div>
<div id="scroller" data-testid="scroller">
${['a','b','c'].map((id, index) => `<article class="row" data-testid="row" data-item-id="${id}"><span class="author">作者 ${id}</span><span class="content">材料需求 ${index+1}</span><a class="source" href="/fixture/actions?item=${id}">来源</a></article>`).join('')}
</div><div data-testid="unsafe"><a class="js" href="javascript:alert(1)">异常链接</a><a class="external" href="https://example.com/">站外</a>
<input class="secret" name="password" type="password" value="DO-NOT-READ"><span class="duplicate">一</span><span class="duplicate">二</span></div>
<div id="covered"><button data-testid="covered">搜索</button><div id="cover">遮挡</div></div>
<button data-testid="post-write" onclick="document.querySelector('#write-state').textContent='已出现局部变化';for(let i=0;i<20;i++)fetch('/fixture/write/data-session-token/abcdefghijklmnopqrstuvwxyz0123456789abcdef?token=QUERY-SECRET',{method:'POST',headers:{'X-Secret':'HEADER-SECRET'},body:'BODY-SECRET'}).catch(()=>{});fetch('/fixture/write/unknown-branch?token=SECOND-SECRET',{method:'POST'}).catch(()=>{})">加载更多</button><div id="write-state" data-testid="write-state">尚未尝试</div>
<div style="height:1400px"></div><a data-testid="below" href="/fixture/actions?selected=below">下方笔记</a>
</main></body></html>`);
return;
}
if (url.pathname === '/fixture/viewport') {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>视口候选</title></head><body>
<div style="position:absolute;top:2000px">${Array.from({length:140}, (_, i) => `<a href="/fixture/viewport?i=${i}" data-testid="off-${i}">下方 ${i}</a>`).join(' ')}</div>
<button data-testid="visible-late">搜索当前页面</button><p hidden>非真人操作提醒隐藏文本</p><div style="opacity:0"><p>透明提示不作为可见提醒</p></div>
<div>${Array.from({length:120},(_,i)=>`<a href="/fixture/viewport?visible=${i}">可见链接${i}</a>`).join(' ')}</div><input id="quota-search" type="search" aria-label="搜索资料" placeholder="候选预算搜索框">
<script>fetch('/fixture/baseline?token=BASELINE-QUERY-SECRET',{method:'POST',body:'BASELINE-BODY-SECRET'}).catch(()=>{})</script></body></html>`);
return;
}
if (url.pathname === '/fixture/popups') {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>弹窗搜索列表</title></head><body><main>
<a data-testid="same-popup" target="_blank" href="/fixture/popup-detail">笔记详情</a>
<a data-testid="opener-popup" target="_blank" rel="opener" href="/fixture/popup-detail">旧页可关闭的笔记详情</a>
<button data-testid="foreign-popup" onclick="window.open('http://localhost:${server.address().port}/fixture/popup-detail?token=POPUP-SECRET','_blank')">展开站外页面</button>
<input data-testid="popup-search" type="search" name="keyword" placeholder="搜索关键词" onkeydown="if(event.key==='Enter')window.open('/fixture/popup-detail','_blank')">
<textarea data-testid="comment" name="comment" placeholder="回复评论"></textarea>
<input data-testid="post-search" name="search" placeholder="搜索" form="post-form"><form id="post-form" method="post"></form>
</main></body></html>`);
return;
}
if (url.pathname === '/fixture/manual-popup') {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
const target = url.searchParams.has('foreign') ? `http://localhost:${server.address().port}/fixture/popup-detail?token=MANUAL-POPUP-SECRET` : '/fixture/popup-detail';
response.end(`<!doctype html><html><head><title>人工阶段页面</title></head><body><main>等待人工确认
<script>setTimeout(()=>window.open(${JSON.stringify(target)},'_blank'),250)</script></main></body></html>`);
return;
}
if (url.pathname === '/fixture/discovery') {
const duplicate = url.searchParams.has('duplicate');
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>实际可读结构发现</title><style>
body{margin:12px}#commentsViewport{height:100px;overflow-y:auto;width:520px}.comment-row{height:100px;box-sizing:border-box;padding:4px}.comment-body{margin:2px 0}
</style></head><body><div id="noteContainer"><h1 id="detail-title">样本笔记标题</h1><a class="note-author" href="/fixture/discovery?author=owner">笔记作者</a>
<div id="detail-desc"><p>样本正文:装修材料需求</p><span style="display:none">隐藏内部提示</span><div id="access-token"><span id="ordinary-child">VISIBLE-CREDENTIAL-SECRET</span></div><div style="display:none"><p id="hidden-body">HIDDEN-ANCESTOR-SECRET</p></div></div>
<div id="commentsViewport">${['甲','乙','丙'].map((name,i) => `<div id="comment-${String(i+1).repeat(24)}" class="comment-row"><a class="comment-author" href="/fixture/discovery?author=${i}">用户${name}</a><div class="comment-body"><span>想了解材料${name}</span></div><time datetime="2026-10-${24-i}">记录日期</time></div>`).join('')}</div>
<section id="notes-list">${['alpha','beta'].map((id,i)=>`<div class="card"><a class="note-title" href="/fixture/discovery?selected=${id}">${duplicate ? '同名笔记' : i ? '笔记乙' : '笔记甲'}</a></div>`).join('')}</section>
<div id="stable">${['alpha','beta'].map((id,i)=>`${'<div>'.repeat(9)}<a class="title" href="/fixture/discovery?selected=deep-${id}">${i ? '深层笔记乙' : '深层笔记甲'}</a>${'</div>'.repeat(9)}`).join('')}</div>
<div id="selection-state">当前 ${escapeHtml(url.searchParams.get('selected') || '无')}</div></div>
<form id="login-widget"><input type="password" value="PRIVATE-NOT-READ"><span>验证码登录</span></form></body></html>`);
return;
}
if (url.pathname === '/fixture/popup-detail') {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>同源笔记详情</title></head><body><main data-testid="detail">实际笔记内容
<button data-testid="child-popup" onclick="window.open('/fixture/popup-detail?generation=child','_blank')">展开子详情</button>
<button data-testid="close-opener" onclick="const source=window.opener;source?.close();setTimeout(()=>document.querySelector('#opener-state').textContent=source?.closed?'旧页面已关闭':source?'旧页面未关闭':'无旧页面引用',30)">收起旧页面</button><p id="opener-state">旧页状态待核对</p></main></body></html>`);
return;
}
if (url.pathname !== '/fixture/search') { response.writeHead(404).end(); return; }
const keyword = escapeHtml(url.searchParams.get('q') || '初始');
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.setHeader('Set-Cookie', 'worker-test-session=retained; Max-Age=86400; Path=/; SameSite=Lax');
response.end(`<!doctype html><html lang="zh-CN"><head><title>本机学习工作进程验收</title></head>
<body><main><h1>材料搜索</h1><form method="get"><input data-testid="query" name="q" placeholder="搜索关键词"><button data-testid="search">搜索</button></form>
<div data-testid="results">${keyword} 的搜索结果</div><div data-testid="cookie-state">${request.headers.cookie?.includes('worker-test-session=retained') ? 'retained' : 'fresh'}</div>
<form><input name="phone" placeholder="手机号" value="13812345678"><button>验证码登录</button></form><p>13812345678</p><button data-testid="send">发送私信</button></main></body></html>`);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const entryUrl = `http://127.0.0.1:${server.address().port}/fixture/search`;
const client = browserClient(home);
let restartedClient;
const passed = (name, detail = {}) => {
results.push({ name, passed: true, ...detail });
console.log(`ok ${results.length} - ${name}`);
};
const rejectCode = (method, params, code) => assert.rejects(() => client.call(method, params), error => error.code === code);
try {
if(process.argv.includes('--metadata-only')){
await metadataRegression(client,entryUrl,passed,home,CHANNEL,semanticChanges);
await seekRegression(client,entryUrl,passed,{bindingNoteId,bindingTitle,detailRequests:()=>boundDetailRequests});
succeeded=true;
await fs.writeFile(path.join(runRoot,'report.json'),JSON.stringify({passed:results.length,results},null,2));
console.log(JSON.stringify({passed:results.length,browser_channel:CHANNEL,artifacts:runRoot,scope:'metadata-only'}));return;
}
if(process.argv.includes('--content-only')){
await contentRegression(client,entryUrl,passed);
succeeded=true;
await fs.writeFile(path.join(runRoot,'report.json'),JSON.stringify({passed:results.length,results},null,2));
console.log(JSON.stringify({passed:results.length,browser_channel:CHANNEL,artifacts:runRoot,scope:'content-only'}));
return;
}
if(process.argv.includes('--budget-only')){
await budgetRegression(entryUrl,home,passed,{bindingNoteId,bindingTitle});
succeeded=true;
await fs.writeFile(path.join(runRoot,'report.json'),JSON.stringify({passed:results.length,results},null,2));
console.log(JSON.stringify({passed:results.length,browser_channel:CHANNEL,artifacts:runRoot,scope:'budget-only'}));
return;
}
if(process.argv.includes('--seek-only')){
await seekRegression(client,entryUrl,passed,{bindingNoteId,bindingTitle,detailRequests:()=>boundDetailRequests});
succeeded=true;
await fs.writeFile(path.join(runRoot,'report.json'),JSON.stringify({passed:results.length,results},null,2));
console.log(JSON.stringify({passed:results.length,browser_channel:CHANNEL,artifacts:runRoot,scope:'seek-only'}));
return;
}
if(process.argv.includes('--viewport-seek-only')){
await viewportRegression(client,entryUrl,passed);
assert.equal(blockedWriteReachedServer,0,'viewport readonly guard prevents the synthetic POST from reaching the fixture');
succeeded=true;
await fs.writeFile(path.join(runRoot,'report.json'),JSON.stringify({passed:results.length,results},null,2));
console.log(JSON.stringify({passed:results.length,browser_channel:CHANNEL,artifacts:runRoot,scope:'viewport-seek-only'}));
return;
}
const syntheticNoteId='abcdef13800138000abcde12';
assert.equal(syntheticNoteId.length,24);
for(const type of ['explore','search_result']){
const source=`https://www.xiaohongshu.com/${type}/${syntheticNoteId}`;
assert.equal(redactUrl(source),source);
const sensitive=redactUrl(source+'?token=FIXTURE-PRIVATE-TOKEN&phone=13800138000&keyword=13800138000#session=FIXTURE-PRIVATE-SESSION');
const parsed=new URL(sensitive);
assert.equal(parsed.pathname,`/${type}/${syntheticNoteId}`);
assert.equal(parsed.searchParams.get('token'),'[已隐藏]');
assert.equal(parsed.searchParams.get('phone'),'[已隐藏]');
assert.equal(parsed.searchParams.get('keyword'),'[手机号已隐藏]');
assert.ok(!sensitive.includes('FIXTURE-PRIVATE-TOKEN') && !sensitive.includes('FIXTURE-PRIVATE-SESSION'));
assert.ok(!redactUrl(source+'#contact=13800138000').includes('#contact=13800138000'));
}
for(const source of [
`https://www.xiaohongshu.com.evil.invalid/explore/${syntheticNoteId}`,
`https://evil.invalid/explore/${syntheticNoteId}`,`https://www.xiaohongshu.com@evil.invalid/explore/${syntheticNoteId}`,
`https://fixture:only@www.xiaohongshu.com/explore/${syntheticNoteId}`,
`http://www.xiaohongshu.com/explore/${syntheticNoteId}`,`https://www.xiaohongshu.com:444/explore/${syntheticNoteId}`,
`https://www.xiaohongshu.com/user/profile/${syntheticNoteId}`,`https://www.xiaohongshu.com/explore/${syntheticNoteId}/`,
`https://www.xiaohongshu.com/explore/${syntheticNoteId}a`,
]){
const safe=redactUrl(source);
assert.ok(!safe.includes(syntheticNoteId),'only the precise official public note path may bypass the phone-like substring filter');
assert.ok(safe.includes('[手机号已隐藏]'));
}
assert.ok(redactUrl('ordinary title 13800138000').includes('[手机号已隐藏]'));
passed('仅官方标准笔记路径保留含手机号形数字的公共hexID,敏感查询/片段和其它来源/账号路径继续脱敏');
const opened = await client.call('open', { task_id: 'regression-a', account_key: 'test-account', entry_url: entryUrl });
assert.equal(opened.status, 'awaiting_user');
await rejectCode('capture', { task_id: 'regression-a' }, 'AWAITING_USER');
passed('首次打开进入人工确认,未确认不能采集');
await rejectCode('open', { task_id: 'regression-b', account_key: 'test-account', entry_url: entryUrl }, 'ACCOUNT_BUSY');
passed('同一账号不能同时被两个学习任务持有');
const confirmation = await client.call('ready', { task_id: 'regression-a', identity_label: '隔离测试账号' });
assert.equal(confirmation.identity_verification, 'operator_confirmed');
assert.equal(confirmation.manual_confirmation, true);
const observation = await client.call('capture', { task_id: 'regression-a', note: '本机测试,无真实平台登录' });
assert.ok(!observation.text.includes('13812345678'));
assert.ok(!observation.elements.some(element => element.kind === 'fill' && /phone|手机号/.test(element.selector + ' ' + element.label)));
const image = await fs.readFile(observation.screenshot_path);
assert.equal(image.subarray(1, 4).toString(), 'PNG');
passed('人工确认来源如实记录,文本及候选排除敏感输入并生成实际 PNG');
await rejectCode('action', { task_id: 'regression-a', kind: 'click', selector: '[data-testid="send"]' }, 'WRITE_ACTION_BLOCKED');
passed('拒绝发送私信动作');
await rejectCode('action', { task_id: 'regression-a', kind: 'press', selector: '[data-testid="query"]', key: 'Tab' }, 'INVALID_INPUT');
await rejectCode('action', { task_id: 'regression-a', kind: 'press', selector: 'input[name="phone"]', key: 'Enter' }, 'UNOBSERVED_SELECTOR');
const pressSearch = await client.call('verify', { task_id: 'regression-a', entry_url: entryUrl,
steps: [{kind:'fill',selector:'[data-testid="query"]',parameter:'keyword'},
{kind:'press',selector:'[data-testid="query"]',key:'Enter'},
{kind:'read',selector:'[data-testid="results"]',output_name:'results'}],
parameters: { keyword: '装修' }, expected_outputs: { results: '装修' } });
assert.equal(pressSearch.passed, true, JSON.stringify(pressSearch.errors));
passed('搜索输入框仅允许Enter,实际GET搜索可参数化重放,敏感字段与其他按键拒绝');
const steps = [
{ kind: 'fill', selector: '[data-testid="query"]', parameter: 'keyword' },
{ kind: 'click', selector: '[data-testid="search"]' },
{ kind: 'read', selector: '[data-testid="results"]', output_name: 'results' },
];
const verified = await client.call('verify', { task_id: 'regression-a', entry_url: entryUrl, steps,
parameters: { keyword: '木材' }, expected_outputs: { results: '木材' } });
assert.equal(verified.passed, true, JSON.stringify(verified.errors));
assert.equal(verified.os, process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : process.platform);
assert.ok(verified.outputs.results.some(item => item.text.includes('木材')));
assert.ok(verified.evidence.length >= 4);
passed('参数化填写 搜索 读回实际浏览器结果通过', { os: verified.os, browser_version: verified.browser_version });
const failed = await client.call('verify', { task_id: 'regression-a', entry_url: entryUrl, steps,
parameters: { keyword: '水泥' }, expected_outputs: { results: '钢材' } });
assert.equal(failed.passed, false);
assert.ok(failed.errors.length > 0);
assert.ok(failed.outputs.results.some(item => item.text.includes('水泥')));
passed('错误输出预期保留实际读回并判失败');
await rejectCode('verify', { task_id: 'regression-a', entry_url: entryUrl, steps,
parameters: { keyword: '木材' }, expected_outputs: { results: '' } }, 'INVALID_EXPECTED_OUTPUT');
passed('拒绝用空输出预期取得验证通过');
await client.call('close', { task_id: 'regression-a' });
const actionUrl = new URL('/fixture/actions', entryUrl).href;
const task = 'restricted-actions';
await client.call('open', { task_id: task, account_key: 'test-account', entry_url: actionUrl });
await client.call('ready', { task_id: task, identity_label: '受限动作样本账号' });
await client.call('capture', { task_id: task });
const act = params => client.call('action', { task_id: task, ...params });
const fields = [
{ name: 'author', selector: '.author', source: 'text', required: true },
{ name: 'content', selector: '.content', source: 'text', required: true },
{ name: 'item_id', source: 'attribute', attribute: 'data-item-id', required: true },
{ name: 'url', selector: '.source', source: 'href', required: true },
{ name: 'optional', selector: '.missing', source: 'text' },
];
const extracted = await act({ kind: 'extract', selector: '[data-testid="row"]', fields, limit: 1, output_name: 'rows' });
assert.equal(extracted.result.count, 1);
assert.equal(extracted.result.items[0].author, '作者 a');
assert.equal(extracted.result.items[0].optional, null);
assert.equal(extracted.result.coverage, 'limit_reached');
assert.equal(extracted.result.truncated, true);
assert.equal(extracted.result.source_url, actionUrl);
assert.ok(extracted.before.capture_id !== extracted.after.capture_id);
passed('结构化提取绑定来源、可选空字段、限制及前后证据');
const dynamic = await act({ kind: 'extract', selector: '[data-testid="row"]', fields, limit: 10, output_name: 'chosen',
target: { field: 'attribute', attribute: 'data-item-id', value: 'b' } });
assert.equal(dynamic.result.items[0].author, '作者 b');
await assert.rejects(() => act({ kind: 'extract', selector: '[data-testid="row"]', fields, limit: 1, output_name: 'missing',
target: { field: 'attribute', attribute: 'data-item-id', value: 'unknown' } }), error => error.code === 'NON_UNIQUE_TARGET');
await assert.rejects(() => act({ kind: 'click', selector: '[data-testid="post"]' }), error => error.code === 'NON_UNIQUE_TARGET');
passed('动态对象按实际字段唯一匹配,缺失及多匹配不默选首项');
for (const [selector, source, attribute, expected] of [
['.secret', 'text', undefined, 'SENSITIVE_OUTPUT'],
['.js', 'href', undefined, 'UNSAFE_OUTPUT'],
['.external', 'href', undefined, 'UNSAFE_OUTPUT'],
[undefined, 'attribute', 'data-session-token', 'INVALID_INPUT'],
['.duplicate', 'text', undefined, 'NON_UNIQUE_FIELD'],
]) {
await assert.rejects(() => act({ kind: 'extract', selector: '[data-testid="unsafe"]', fields: [{ name: 'value', selector, source, attribute }],
limit: 1, output_name: 'unsafe' }), error => error.code === expected);
}
await assert.rejects(() => act({ kind: 'extract', selector: 'xpath=//article', fields, limit: 1, output_name: 'unsafe' }), error => error.code === 'INVALID_INPUT');
passed('结构化提取拒绝凭据、脚本/跨站链接、敏感属性、歧义字段及非CSS定位');
const scroll = await act({ kind: 'scroll', selector: '[data-testid="scroller"]', direction: 'down', pixels: 80, max_scrolls: 2 });
assert.ok(scroll.result.movements.some(movement => movement.after > movement.before));
assert.ok(scroll.result.scrolls <= 2);
const bottom = await act({ kind: 'extract', selector: '[data-testid="row"]', fields, limit: 10, output_name: 'rows' });
assert.ok(bottom.result.items.some(item => item.author === '作者 c'));
assert.ok(!bottom.result.items.some(item => item.author === '作者 a'));
assert.equal(bottom.result.truncated, true);
assert.equal(bottom.result.coverage, 'bounded', 'viewport omission is distinct from reaching the requested item limit');
const offscreen = await act({ kind: 'extract', selector: '[data-testid="below"]',
fields: [{ name: 'text', source: 'text' }], limit: 10, output_name: 'offscreen' });
assert.equal(offscreen.result.count, 0);
assert.equal(offscreen.result.truncated, true);
assert.equal(offscreen.result.coverage, 'empty');
await assert.rejects(() => act({ kind: 'scroll', selector: 'body', direction: 'down', pixels: 80, max_scrolls: 11 }), error => error.code === 'INVALID_INPUT');
passed('容器滚动有界、留下位移证据,只提取滚动后可见行');
await act({ kind: 'click', selector: '[data-testid="expand"]' });
const hidden = await act({ kind: 'wait', selector: '[data-testid="pending"]', state: 'hidden', timeout_ms: 1000 });
assert.equal(hidden.result.satisfied, true);
const waited = await act({ kind: 'wait', selector: '[data-testid="delayed"]', state: 'text', text: '加载完成', timeout_ms: 1500 });
assert.equal(waited.result.satisfied, true);
await assert.rejects(() => act({ kind: 'wait', selector: '[data-testid="delayed"]', state: 'text', text: '不会出现', timeout_ms: 100 }), error => error.code === 'WAIT_TIMEOUT');
passed('条件等待支持已移除对象、异步文本及明确超时失败');
await assert.rejects(() => act({ kind: 'click', selector: '[data-testid="covered"]' }), error => error.code === 'TARGET_OBSCURED');
await assert.rejects(() => act({ kind: 'click', selector: '[data-testid="below"]' }), error => error.code === 'TARGET_NOT_IN_VIEWPORT');
await act({ kind: 'scroll', selector: 'body', direction: 'down', pixels: 1600, max_scrolls: 2 });
const below = await act({ kind: 'click', selector: '[data-testid="below"]' });
assert.match(below.result.url, /selected=below/);
passed('点击拒绝遮挡和视口外目标,显式滚动后才能执行');
const dynamicSteps = [{ kind: 'click', selector: '[data-testid="post"]',
target: { field: 'attribute', attribute: 'data-item-id', parameter: 'post_id' } },
{ kind: 'read', selector: '[data-testid="selected"]', output_name: 'selected' }];
const verifyDynamic = await client.call('verify', { task_id: task, entry_url: `${actionUrl}?order=reverse`, steps: dynamicSteps,
parameters: { post_id: 'b' }, expected_outputs: { selected: '当前 b' } });
assert.equal(verifyDynamic.passed, true, JSON.stringify(verifyDynamic.errors));
const structuredVerify = await client.call('verify', { task_id: task, entry_url: actionUrl,
steps: [{ kind: 'extract', selector: '[data-testid="row"]', fields, limit: 2, output_name: 'rows' }],
parameters: {}, expected_outputs: { rows: '材料需求 2' } });
assert.equal(structuredVerify.passed, true, JSON.stringify(structuredVerify.errors));
const urlOnly = await client.call('verify', { task_id: task, entry_url: actionUrl,
steps: [{ kind: 'extract', selector: '[data-testid="row"]', fields: [fields[3]], limit: 2, output_name: 'rows' }],
parameters: {}, expected_outputs: { rows: '/fixture/actions' } });
assert.equal(urlOnly.passed, false, 'URL alone cannot satisfy a content assertion');
passed('重放按变化参数选择实际对象,结构化内容可断言且URL不能冒充内容');
const waitVerify = await client.call('verify', { task_id: task, entry_url: actionUrl,
steps: [{ kind: 'click', selector: '[data-testid="expand"]' },
{ kind: 'wait', selector: '[data-testid="pending"]', state: 'hidden', timeout_ms: 1000 },
{ kind: 'wait', selector: '[data-testid="delayed"]', state: 'text', text: '加载完成', timeout_ms: 1500 },
{ kind: 'read', selector: '[data-testid="delayed"]', output_name: 'loaded' }],
parameters: {}, expected_outputs: { loaded: '加载完成' } });
assert.equal(waitVerify.passed, true, JSON.stringify(waitVerify.errors));
let failure;
await assert.rejects(() => act({ kind: 'click', selector: '[data-testid="post-write"]' }), error => {
failure = error;
return error.code === 'WRITE_REQUEST_BLOCKED';
});
assert.ok(failure.before?.capture_id && failure.after?.capture_id);
assert.ok(failure.after.text.includes('已出现局部变化'));
assert.equal(failure.diagnostics.phase, 'request_guard');
assert.equal(failure.diagnostics.blocked_request_count, 21);
assert.equal(failure.diagnostics.blocked_requests.length, 2);
assert.equal(failure.diagnostics.blocked_requests.filter(item => item.pathname === '/fixture/write/:redacted/:id').length, 1, '20 identical requests keep one safe summary');
assert.ok(failure.diagnostics.blocked_requests.some(item => item.pathname === '/fixture/write/unknown-branch'), 'a distinct unknown request must not be buried by repeated background traffic');
assert.equal(blockedWriteReachedServer, 0);
assert.ok(failure.after.request_observations.some(item => item.blocked && item.automating && item.method === 'POST'));
for (const request of failure.diagnostics.blocked_requests) {
assert.deepEqual(Object.keys(request).sort(), ['method','origin','pathname','policy_reason','resource_type']);
assert.equal(request.policy_reason, 'missing_policy');
}
const serializedFailure = JSON.stringify({ diagnostics: failure.diagnostics, requests: failure.after.request_observations });
for (const secret of ['QUERY-SECRET','SECOND-SECRET','HEADER-SECRET','BODY-SECRET','abcdefghijklmnopqrstuvwxyz0123456789abcdef','data-session-token','?token=']) assert.ok(!serializedFailure.includes(secret));
await client.call('close', { task_id: task });
passed('条件等待可重放;写请求失败保存真实前后状态与有界脱敏诊断,未重放或发送POST');
const viewportTask = 'viewport-observation';
await client.call('open', { task_id: viewportTask, account_key: 'test-account', entry_url: new URL('/fixture/viewport', entryUrl).href });
await client.call('ready', { task_id: viewportTask, identity_label: '可见性样本账号' });
const viewportCapture = await client.call('capture', { task_id: viewportTask });
assert.ok(viewportCapture.elements.some(item => item.selector === '[data-testid="visible-late"]' && item.in_viewport));
assert.ok(viewportCapture.elements.some(item => item.selector === '#quota-search' && item.search_input && item.unique),'many read roots/links must not exhaust the search input quota');
assert.ok(viewportCapture.elements.filter(item => !item.in_viewport).length <= 30);
assert.ok(viewportCapture.elements.length <= 120);
assert.ok(!viewportCapture.text.includes('非真人操作提醒隐藏文本'));
assert.ok(!viewportCapture.text.includes('透明提示不作为可见提醒'));
assert.ok(viewportCapture.viewport_text.includes('搜索当前页面'));
assert.ok(baselineRequestCount > 0);
assert.ok(viewportCapture.request_observations.some(item => item.method === 'POST' && !item.blocked && !item.automating && item.pathname === '/fixture/baseline'));
assert.ok(!JSON.stringify(viewportCapture.request_observations).includes('BASELINE-QUERY-SECRET'));
assert.ok(!JSON.stringify(viewportCapture.request_observations).includes('BASELINE-BODY-SECRET'));
await client.call('close', { task_id: viewportTask });
passed('视口候选优先且离屏有界,隐藏正文不作可见提示,被动请求摘要区分允许与拦截且不含查询正文');
const popupEntry = new URL('/fixture/popups', entryUrl).href;
const popupTask = 'same-origin-popup';
await client.call('open', { task_id: popupTask, account_key: 'test-account', entry_url: popupEntry });
await client.call('ready', { task_id: popupTask, identity_label: '同源弹窗样本账号' });
await client.call('capture', { task_id: popupTask });
await assert.rejects(() => client.call('action', { task_id: popupTask, kind: 'press', selector: '[data-testid="comment"]', key:'Enter' }), error => error.code === 'WRITE_ACTION_BLOCKED');
await assert.rejects(() => client.call('action', { task_id: popupTask, kind: 'press', selector: '[data-testid="post-search"]', key:'Enter' }), error => error.code === 'WRITE_ACTION_BLOCKED');
const popupClicked = await client.call('action', { task_id: popupTask, kind: 'click', selector: '[data-testid="same-popup"]' });
assert.match(popupClicked.result.url, /\/fixture\/popup-detail$/);
assert.match(popupClicked.after.text, /实际笔记内容/);
assert.equal((await client.call('status', { task_id: popupTask })).status, 'observing');
await client.call('close', { task_id: popupTask });
const openerTask = 'popup-retained-opener';
await client.call('open', { task_id:openerTask, account_key:'test-account', entry_url:popupEntry });
await client.call('ready', { task_id:openerTask, identity_label:'旧页关闭样本账号' });
await client.call('capture', { task_id:openerTask });
await client.call('action', { task_id:openerTask, kind:'click', selector:'[data-testid="opener-popup"]' });
// A script-opened intermediate page is actually permitted to close. The
// browser forbids closing its original UI-created tab from page scripts.
await client.call('action', { task_id:openerTask, kind:'click', selector:'[data-testid="child-popup"]' });
await client.call('action', { task_id: openerTask, kind: 'click', selector: '[data-testid="close-opener"]' });
const closedOpener = await client.call('capture', { task_id: openerTask });
assert.match(closedOpener.text, /实际笔记内容/);
assert.match(closedOpener.text, /旧页面已关闭/);
await client.call('close', { task_id:openerTask });
passed('明确点击的同源新页沿用会话及人工确认,旧搜索页关闭不误关详情,评论及POST搜索拒绝Enter');
for (const [suffix, kind, selector, expected] of [
['foreign', 'click', '[data-testid="foreign-popup"]', ['POPUP_BLOCKED','SCOPE_BLOCKED']],
['unknown', 'press', '[data-testid="popup-search"]', ['POPUP_BLOCKED']],
]) {
const id = `popup-${suffix}`;
await client.call('open', { task_id:id, account_key:'test-account', entry_url:popupEntry });
await client.call('ready', { task_id:id, identity_label:'异常弹窗样本账号' });
await client.call('capture', { task_id:id });
await assert.rejects(() => client.call('action', {task_id:id, kind, selector, ...(kind==='press'?{key:'Enter'}:{})}), error => {
assert.ok(error.before && error.after);
assert.ok(!JSON.stringify(error.diagnostics).includes('POPUP-SECRET'));
return expected.includes(error.code);
});
await assert.rejects(() => client.call('capture', {task_id:id}), error => expected.includes(error.code));
await client.call('close', {task_id:id});
}
passed('出域及非明确点击产生的新页关闭并暂停,失败取证不自动追踪或操作未知页面');
for (const foreign of [false, true]) {
const id = foreign ? 'manual-foreign-popup' : 'manual-same-origin-popup';
const manualEntry = new URL(`/fixture/manual-popup${foreign ? '?foreign=1' : ''}`, entryUrl).href;
await client.call('open', { task_id:id, account_key:'test-account', entry_url:manualEntry });
let state;
const deadline = Date.now() + 6000;
do {
state = await client.call('status', {task_id:id});
if (foreign ? state.block_reason && state.popup_observations.length : state.url.endsWith('/fixture/popup-detail')) break;
await new Promise(resolve => setTimeout(resolve, 100));
} while (Date.now() < deadline);
assert.equal(state.status, 'awaiting_user');
assert.equal(state.manual_confirmation, false);
if (foreign) {
assert.ok(['scope_blocked','popup_blocked'].includes(state.block_reason));
await assert.rejects(() => client.call('ready', {task_id:id,identity_label:'人工阶段异常样本'}), error => ['SCOPE_BLOCKED','POPUP_BLOCKED'].includes(error.code));
} else {
assert.equal(state.block_reason, null);
assert.match(state.url, /\/fixture\/popup-detail$/);
await rejectCode('capture', {task_id:id}, 'AWAITING_USER');
await client.call('ready', {task_id:id,identity_label:'人工阶段同源样本'});
const manualCapture = await client.call('capture', {task_id:id});
assert.ok(manualCapture.text.includes('实际笔记内容'));
assert.ok(manualCapture.popup_observations.some(item => item.phase === 'manual' && item.decision === 'adopted' && item.reason === 'same_origin_manual'));
}
state = await client.call('status', {task_id:id});
assert.ok(state.popup_observations.length <= 10);
assert.ok(!JSON.stringify(state.popup_observations).includes('MANUAL-POPUP-SECRET'));
await client.call('close', {task_id:id});
passed(foreign ? '人工确认前出域新页仍阻止,status暴露实际阻断原因而非伪确认' : '人工确认前同源新页可接续但仍不可采集,显式确认后留真实事件摘要');
}
const discoveryTask = 'readable-discovery';
const discoveryUrl = new URL('/fixture/discovery', entryUrl).href;
await client.call('open', {task_id:discoveryTask,account_key:'test-account',entry_url:discoveryUrl});
await client.call('ready', {task_id:discoveryTask,identity_label:'可读结构样本'});
const discovered = await client.call('capture', {task_id:discoveryTask});
assert.ok(discovered.elements.some(item=>item.selector==='#noteContainer' && item.kind==='read'));
assert.ok(discovered.elements.some(item=>item.selector==='#detail-desc' && item.kind==='read'));
assert.ok(discovered.elements.some(item=>item.selector.startsWith('#comment-') && item.kind==='read'));
const scroller = discovered.elements.find(item=>item.selector==='#commentsViewport');
assert.ok(scroller.scroll_y.can_scroll);
const commentRows = discovered.elements.find(item=>item.collection && item.selector_basis==='id_family' && item.selector.includes('comment-'));
assert.ok(commentRows?.source_selectors.length);
const authorHint = commentRows.read_hints.find(item=>item.link && item.label==='用户甲');
const bodyHint = commentRows.read_hints.find(item=>item.source==='text' && item.label==='想了解材料甲');
const dateHint = commentRows.read_hints.find(item=>item.source==='attribute' && item.attribute==='datetime');
assert.ok(authorHint && bodyHint && dateHint, JSON.stringify(commentRows));
assert.ok(discovered.elements.length<=120 && discovered.elements.filter(item=>!item.in_viewport).length<=30);
assert.ok(!JSON.stringify(discovered.elements).includes('PRIVATE-NOT-READ'));
assert.ok(!JSON.stringify(discovered.elements).includes('隐藏内部提示'));
for (const value of ['VISIBLE-CREDENTIAL-SECRET','HIDDEN-ANCESTOR-SECRET','access-token','ordinary-child','hidden-body']) {
assert.ok(!JSON.stringify(discovered.elements).includes(value));
assert.ok(!discovered.text.includes(value));
assert.ok(!discovered.viewport_text.includes(value));
}
const discoveryAction = params => client.call('action',{task_id:discoveryTask,...params});
const bodyRead = await discoveryAction({kind:'read',selector:'#detail-desc',output_name:'body'});
assert.ok(bodyRead.result[0].text.includes('样本正文'));
assert.ok(!bodyRead.result[0].text.includes('SECRET'));
const bodyExtract = await discoveryAction({kind:'extract',selector:'#detail-desc',fields:[{name:'body',source:'text'}],limit:1,output_name:'body'});
assert.ok(!bodyExtract.result.items[0].body.includes('SECRET'));
const commentFields = [{name:'author',selector:authorHint.selector,source:'text',required:true},
{name:'content',selector:bodyHint.selector,source:'text',required:true},
{name:'source',selector:authorHint.selector,source:'href',required:true},
{name:'comment_id',source:'attribute',attribute:'id',required:true},
{name:'time',selector:dateHint.selector,source:'attribute',attribute:'datetime',required:true}];
const firstComments = await discoveryAction({kind:'extract',selector:commentRows.selector,fields:commentFields,limit:3,output_name:'comments'});
assert.equal(firstComments.result.count,1);
assert.equal(firstComments.result.items[0].author,'用户甲');
await discoveryAction({kind:'scroll',selector:scroller.selector,direction:'down',pixels:100,max_scrolls:1});
const nextComments = await discoveryAction({kind:'extract',selector:commentRows.selector,fields:commentFields,limit:3,output_name:'comments'});
assert.equal(nextComments.result.items[0].content,'想了解材料乙');
passed('实际id/标题/滚动根及id族可读集合有界发现,用真实相对定位映射评论并只滚动评论容器');
const titles = discovered.elements.find(item=>item.collection && item.tag==='a' && item.selector.includes('note-title'));
assert.ok(titles && titles.kind==='click' && !titles.unique && titles.count===2);
await assert.rejects(()=>discoveryAction({kind:'click',selector:titles.selector}), error=>error.code==='NON_UNIQUE_TARGET');
await assert.rejects(()=>discoveryAction({kind:'click',selector:titles.selector,target:{field:'text',value:'不存在'}}), error=>error.code==='NON_UNIQUE_TARGET');
const titleClicked = await discoveryAction({kind:'click',selector:titles.selector,target:{field:'text',value:'笔记乙'}});
assert.match(titleClicked.result.url,/selected=beta/);
const deepTitles = discovered.elements.find(item=>item.selector==='#stable a.title');
assert.ok(deepTitles && deepTitles.collection && deepTitles.kind==='click' && !deepTitles.unique && deepTitles.count===2);
await assert.rejects(()=>discoveryAction({kind:'click',selector:deepTitles.selector}), error=>error.code==='NON_UNIQUE_TARGET');
const deepClicked = await discoveryAction({kind:'click',selector:deepTitles.selector,target:{field:'text',value:'深层笔记乙'}});
assert.match(deepClicked.result.url,/selected=deep-beta/);
await client.call('close',{task_id:discoveryTask});
const duplicateTask = 'discovery-duplicate-target';
await client.call('open',{task_id:duplicateTask,account_key:'test-account',entry_url:discoveryUrl+'?duplicate=1'});
await client.call('ready',{task_id:duplicateTask,identity_label:'重复对象样本'});
await client.call('capture',{task_id:duplicateTask});
await assert.rejects(()=>client.call('action',{task_id:duplicateTask,kind:'click',selector:titles.selector,target:{field:'text',value:'同名笔记'}}), error=>error.code==='NON_UNIQUE_TARGET');
await client.call('close',{task_id:duplicateTask});
passed('实际重复a安全class集合支持唯一文本目标;无target、零匹配及同名多匹配不点击');
const semanticUrl = new URL('/fixture/search-semantics',entryUrl).href;
const placeholderTask = 'placeholder-only-search';
await client.call('open',{task_id:placeholderTask,account_key:'test-account',entry_url:semanticUrl});
await client.call('ready',{task_id:placeholderTask,identity_label:'搜索语义样本'});
const semanticCapture = await client.call('capture',{task_id:placeholderTask});
const stableSearch = semanticCapture.elements.find(item=>item.selector==='#semantic-search');
assert.ok(stableSearch.search_input && stableSearch.stable_label.length>0 && stableSearch.stable_label.length<=500);
assert.ok(!stableSearch.stable_label.includes('示例'));
await new Promise(resolve=>setTimeout(resolve,160));
await client.call('action',{task_id:placeholderTask,kind:'fill',selector:'#semantic-search',value:'装修'});
await new Promise(resolve=>setTimeout(resolve,160));
const entered = await client.call('action',{task_id:placeholderTask,kind:'press',selector:'#semantic-search',key:'Enter'});
assert.ok(entered.after.text.includes('装修 的结果'));
await new Promise(resolve=>setTimeout(resolve,160));
await assert.rejects(()=>client.call('action',{task_id:placeholderTask,kind:'fill',selector:'#plain-field',value:'不应执行'}),error=>error.code==='STALE_TARGET');
await client.call('close',{task_id:placeholderTask});
passed('固定搜索语义不受轮换placeholder影响,实际fill/Enter仍成功;非搜索框保持原严格检查');
for (const mode of ['aria','intent','missing']) {
const id = `semantic-${mode}`;
await client.call('open',{task_id:id,account_key:'test-account',entry_url:`${semanticUrl}?mode=${mode}`});
await client.call('ready',{task_id:id,identity_label:'搜索变化拒绝样本'});
await client.call('capture',{task_id:id});
const change = semanticChanges.get(mode);
assert.ok(change);
change.writeHead(200,{'Content-Type':'application/json'}).end('{}');
await new Promise(resolve=>setTimeout(resolve,160));
await assert.rejects(()=>client.call('action',{task_id:id,kind:'fill',selector:'#semantic-search',value:'不应执行'}),error=>{
assert.equal(error.diagnostics.phase,'inspect_current');
return error.code === (mode==='missing' ? 'UNOBSERVED_CURRENT_SELECTOR' : 'STALE_TARGET');
});
await client.call('close',{task_id:id});
}
passed('aria/搜索意图变化继续STALE;当前采集缺失定位单独安全诊断且不放宽执行');
const clipTask='complete-clip-fields';
const windowUrl=new URL('/fixture/read-window',entryUrl).href;
await client.call('open',{task_id:clipTask,account_key:'test-account',entry_url:windowUrl});
await client.call('ready',{task_id:clipTask,identity_label:'裁剪字段样本'});
await client.call('capture',{task_id:clipTask});
const clipAction=params=>client.call('action',{task_id:clipTask,...params});
const untilBase={kind:'scroll',selector:'#read-window',direction:'down',pixels:240,max_scrolls:3,until_selector:'.comment-item'};
const zeroScroll=await clipAction(untilBase);
assert.equal(zeroScroll.result.scrolls,0);
assert.equal(zeroScroll.result.until_reached,true);
const clipFields=[{name:'author',selector:'.author',source:'text',required:true},{name:'content',selector:'.content',source:'text',required:true}];
const complete=await clipAction({kind:'extract',selector:'#read-window .comment-item',fields:clipFields,limit:3,output_name:'comments',complete_only:true});
assert.equal(complete.result.count,1);
assert.equal(complete.result.items[0].author,'作者甲');
assert.equal(complete.result.skipped_incomplete_count,1);
assert.equal(complete.result.truncated,true);
assert.equal(complete.result.coverage,'bounded');
for(const [field,code] of [
[{name:'missing',selector:'.missing',source:'text',required:true},'REQUIRED_FIELD_MISSING'],
[{name:'hidden',selector:'.hidden-field',source:'text',required:true},'REQUIRED_FIELD_MISSING'],
[{name:'empty',selector:'.empty',source:'text',required:true},'REQUIRED_FIELD_MISSING'],
[{name:'private',selector:'.secret-field',source:'text',required:true},'SENSITIVE_OUTPUT'],
[{name:'duplicate',selector:'.duplicate',source:'text',required:true},'NON_UNIQUE_FIELD'],
]) await assert.rejects(()=>clipAction({kind:'extract',selector:'#partial-row',fields:[clipFields[1],field],limit:1,output_name:'comments',complete_only:true}),error=>error.code===code);
await assert.rejects(()=>clipAction({...untilBase,until_selector:'body .comment-item'}),error=>error.code==='INVALID_INPUT');
await assert.rejects(()=>clipAction({...untilBase,until_selector:'.secret-field'}),error=>error.code==='SENSITIVE_OUTPUT');
await assert.rejects(()=>clipAction({kind:'extract',selector:'#first-row',fields:clipFields,limit:1,output_name:'comments',complete_only:'true'}),error=>error.code==='INVALID_INPUT');
await client.call('close',{task_id:clipTask});
passed('until初始完整行可0滚;complete_only只跳过裁剪行,后续真实缺失/隐藏/空值/敏感/歧义仍失败');
const longTask='until-long-content';
await client.call('open',{task_id:longTask,account_key:'test-account',entry_url:windowUrl+'?mode=long'});
await client.call('ready',{task_id:longTask,identity_label:'长正文有界滚动样本'});
await client.call('capture',{task_id:longTask});
const longAction=params=>client.call('action',{task_id:longTask,...params});
await assert.rejects(()=>longAction({...untilBase,pixels:120,max_scrolls:1}),error=>{
assert.ok(error.before && error.after);return error.code==='SCROLL_BUDGET_EXHAUSTED';
});
const reached=await longAction(untilBase);
assert.ok(reached.result.until_reached && reached.result.scrolls<=3);
const afterLong=await longAction({kind:'extract',selector:'#read-window .comment-item',fields:clipFields,limit:3,output_name:'comments',complete_only:true});
assert.ok(afterLong.result.count>=1 && afterLong.result.count<=3);
await assert.rejects(()=>longAction({...untilBase,until_selector:'.never',max_scrolls:3}),error=>{
assert.ok(error.before && error.after);return error.code==='SCROLL_BOUNDARY_BEFORE_TARGET';
});
await client.call('close',{task_id:longTask});
const lazyTask='until-lazy-boundary';
await client.call('open',{task_id:lazyTask,account_key:'test-account',entry_url:windowUrl+'?mode=lazy'});
await client.call('ready',{task_id:lazyTask,identity_label:'边界懒加载样本'});
await client.call('capture',{task_id:lazyTask});
const lazy=await client.call('action',{task_id:lazyTask,...untilBase});
assert.ok(lazy.result.until_reached && lazy.result.scrolls<=3);
assert.ok(lazy.after.text.includes('评论完整正文延迟'));
await client.call('close',{task_id:lazyTask});
passed('长正文按条件有界滚动,预算/边界未到留证失败;边界懒加载固定轮询后成功且不重放滚动');
const batchUrl=new URL('/fixture/batch-read',entryUrl).href;
const batchFields=[{name:'item_id',source:'attribute',attribute:'data-item-id',required:true},
{name:'author',selector:'.batch-author',source:'text',required:true},{name:'content',selector:'.batch-body',source:'text',required:true}];
const batchParams={kind:'extract',selector:'#batch-container',row_selector:'.batch-row',fields:batchFields,limit:20,output_name:'comments',complete_only:true,
batch:{scroll_selector:'#batch-window',pixels:80,max_batches:3,dedupe_field:'item_id'}};
const openBatch=async(id,url=batchUrl)=>{
await client.call('open',{task_id:id,account_key:'test-account',entry_url:url});
await client.call('ready',{task_id:id,identity_label:'批次读取隔离样本'});
return client.call('capture',{task_id:id});
};
const batchTask='bounded-batches';
const batchCapture=await openBatch(batchTask);
assert.ok(batchCapture.elements.some(item=>item.selector==='#batch-container' && item.unique));
assert.ok(batchCapture.elements.some(item=>item.selector==='#batch-window' && item.unique));
const multiple=await client.call('action',{task_id:batchTask,...batchParams});
assert.deepEqual(multiple.result.items.map(item=>item.item_id),['a','b','c','d']);
assert.deepEqual(multiple.result.batch_summary,{batches_read:3,duplicates_removed:2,stop_reason:'batch_budget'});
assert.equal(multiple.result.skipped_incomplete_count,3);
assert.equal(multiple.batch_evidence.length,3);
assert.deepEqual(multiple.batch_evidence.map(item=>[item.batch_index,item.added_count,item.duplicate_count]),[[1,2,0],[2,1,1],[3,1,1]]);
assert.ok(multiple.batch_evidence[0].capture.viewport_text.includes('正文a'),'first batch is captured before scrolling');
assert.ok(!multiple.batch_evidence[2].capture.viewport_text.includes('正文a'),'later actual viewport is a distinct page capture');
assert.equal(new Set(multiple.batch_evidence.map(item=>item.capture.capture_id)).size,3);
for(const evidence of multiple.batch_evidence){const bytes=await fs.readFile(evidence.capture.screenshot_path);assert.equal(bytes.subarray(1,4).toString(),'PNG');}
await client.call('close',{task_id:batchTask});
passed('容器内多批先读首批再滚动,完整行按业务id聚合去重,各批保留独立真实PNG和来源');
const boundaryTask='batch-boundary';
await openBatch(boundaryTask,batchUrl+'?mode=boundary');
const bounded=await client.call('action',{task_id:boundaryTask,...batchParams,batch:{...batchParams.batch,max_batches:5}});
assert.deepEqual(bounded.result.items.map(item=>item.item_id),['a','b','c']);
assert.equal(bounded.result.batch_summary.stop_reason,'boundary');
assert.equal(bounded.result.batch_summary.batches_read,2);
const limitReplay=await client.call('verify',{task_id:boundaryTask,entry_url:batchUrl,
steps:[{...batchParams,limit:3}],parameters:{},expected_outputs:{comments:'正文c'}});
assert.equal(limitReplay.passed,true,JSON.stringify(limitReplay.errors));
assert.equal(limitReplay.outputs.comments.batch_summary.stop_reason,'limit_reached');
assert.equal(limitReplay.evidence.length,4,'entry, both batch pages, and final after evidence');
assert.deepEqual(limitReplay.error_codes,[]);
await client.call('close',{task_id:boundaryTask});
passed('批次边界与总条数预算明确结束,重放保留每批证据且不将预算当完整覆盖');
for(const [mode,expected] of [['fit',['a','b']],['lazy',['a','b','c','d']]]){
const id=`batch-${mode}`,observed=await openBatch(id,`${batchUrl}?mode=${mode}`);
if(mode==='fit') assert.equal(observed.elements.find(item=>item.selector==='#batch-window').scroll_y.can_scroll,false);
const read=await client.call('action',{task_id:id,...batchParams,batch:{...batchParams.batch,max_batches:5}});
assert.deepEqual(read.result.items.map(item=>item.item_id),expected);
assert.equal(read.result.batch_summary.stop_reason,'boundary');
if(mode==='fit') assert.equal(read.result.batch_summary.batches_read,1);
await client.call('close',{task_id:id});
}
passed('无溢出的已观察滚动容器仍先读首批再边界结束;边界同高度懒加载新业务id重新读取,不把0位移当内容完整');
for(const [mode,code,minimumEvidence] of [['conflict','DUPLICATE_ID_CONFLICT',2],['source','SOURCE_CHANGED',1],['root','SOURCE_CHANGED',1],['blocked','WRITE_REQUEST_BLOCKED',1]]){
const id=`batch-reject-${mode}`;
await openBatch(id,`${batchUrl}?mode=${mode}`);
await assert.rejects(()=>client.call('action',{task_id:id,...batchParams}),error=>{
assert.ok(error.before && error.after);
assert.ok(error.batch_evidence.length>=minimumEvidence);
assert.equal(new Set(error.batch_evidence.map(item=>item.capture.capture_id)).size,error.batch_evidence.length);
if(mode==='blocked'){
assert.ok(error.diagnostics.blocked_requests.some(item=>item.pathname==='/fixture/write/batch-unknown'));
assert.ok(!JSON.stringify(error.diagnostics).includes('BATCH-QUERY-SECRET'));
assert.ok(!JSON.stringify(error.diagnostics).includes('BATCH-BODY-SECRET'));
}
return error.code===code;
});
if(mode==='blocked'){
const failedBatchReplay=await client.call('verify',{task_id:id,entry_url:`${batchUrl}?mode=blocked`,steps:[batchParams],parameters:{},expected_outputs:{comments:'正文c'}});
assert.equal(failedBatchReplay.passed,false);
assert.deepEqual(failedBatchReplay.error_codes,['WRITE_REQUEST_BLOCKED']);
assert.ok(failedBatchReplay.evidence.some(capture=>capture.viewport_text.includes('正文a')));
assert.ok(failedBatchReplay.evidence.length>=4,'failed replay retains initial, intermediate batch, and actual failure captures');
}
await client.call('close',{task_id:id});
}
assert.equal(blockedWriteReachedServer,0);
passed('重复业务id字段冲突、来源/根替换和未知POST均停止,失败保存已得批次图并保持请求保护');
const contractTask='batch-contracts';
const contractCapture=await openBatch(contractTask);
for(const params of [
{...batchParams,row_selector:undefined}, {...batchParams,complete_only:false},
{...batchParams,target:{field:'text',value:'任意'}}, {...batchParams,batch:{...batchParams.batch,max_batches:6}},
{...batchParams,batch:{...batchParams.batch,dedupe_field:'content'}}, {...batchParams,batch:{...batchParams.batch,scroll_selector:'body'}},
{...batchParams,batch:{...batchParams.batch,unknown:true}}, {...batchParams,row_selector:'body .batch-row'},
]) await assert.rejects(()=>client.call('action',{task_id:contractTask,...params}),error=>error.code==='INVALID_INPUT');
await assert.rejects(()=>client.call('action',{task_id:contractTask,...batchParams,batch:{...batchParams.batch,scroll_selector:'#other-root'}}),error=>error.code==='SOURCE_CHANGED');
const batchTitles=contractCapture.elements.find(item=>item.selector==='#titles a.title');
assert.ok(batchTitles && batchTitles.collection);
await assert.rejects(()=>client.call('action',{task_id:contractTask,kind:'wait',selector:batchTitles.selector,state:'visible',timeout_ms:300}),error=>error.code==='NON_UNIQUE_TARGET');
const collectionWait=await client.call('action',{task_id:contractTask,kind:'wait',selector:batchTitles.selector,state:'visible',min_count:2,timeout_ms:300});
assert.equal(collectionWait.result.satisfied,true);
await assert.rejects(()=>client.call('action',{task_id:contractTask,kind:'wait',selector:batchTitles.selector,state:'visible',min_count:3,timeout_ms:100}),error=>error.code==='WAIT_TIMEOUT');
await assert.rejects(()=>client.call('action',{task_id:contractTask,kind:'wait',selector:batchTitles.selector,state:'text',text:'标题甲',min_count:1,timeout_ms:100}),error=>error.code==='INVALID_INPUT');
await client.call('close',{task_id:contractTask});
passed('批次合同/范围越界不放宽;无标题首卡后的已观察标题集合显式min_count可等待,默认多匹配仍拒绝');
const delayedTask='new-session-delayed-collection',delayedUrl=new URL('/fixture/delayed-collection',entryUrl).href;
await client.call('open',{task_id:delayedTask,account_key:'test-account',entry_url:delayedUrl});
await client.call('ready',{task_id:delayedTask,identity_label:'无预存集合样本'});
const delayedReplay=await client.call('verify',{task_id:delayedTask,entry_url:delayedUrl,parameters:{},
steps:[{kind:'wait',selector:'#delayed-titles a.title',state:'visible',min_count:2,timeout_ms:5000},
{kind:'read',selector:'#delayed-titles',output_name:'titles'}],expected_outputs:{titles:'标题乙'}});
assert.equal(delayedReplay.passed,true,JSON.stringify(delayedReplay.errors));
assert.ok(!delayedReplay.evidence[0].elements.some(element=>element.selector==='#delayed-titles a.title'));
await client.call('close',{task_id:delayedTask});
passed('全新会话无预存标题集合时,min_count仍从0项等待真实延迟结果,不在等待前误报未观察');
const emptyUrl=new URL('/fixture/empty-read',entryUrl).href;
const emptyParams={kind:'extract',selector:'#empty-container',row_selector:'.empty-row',
fields:[{name:'content',selector:'.content',source:'text',required:true}],limit:3,output_name:'comments',complete_only:true,
allow_empty:true,empty_state:{selector:'.empty-state',text:'暂无评论'}};
const emptyTask='confirmed-empty';
await openBatch(emptyTask,emptyUrl);
const empty=await client.call('action',{task_id:emptyTask,...emptyParams});
assert.deepEqual(empty.result,{items:[],count:0,truncated:false,source_url:emptyUrl,coverage:'empty',skipped_incomplete_count:0,
empty_state:{confirmed:true,selector:'.empty-state',text:'暂无评论'}});
const emptyReplay=await client.call('verify',{task_id:emptyTask,entry_url:emptyUrl,steps:[emptyParams],parameters:{},expected_outputs:{comments:{state:'empty'}}});
assert.equal(emptyReplay.passed,true,JSON.stringify(emptyReplay.errors));
const textStillFails=await client.call('verify',{task_id:emptyTask,entry_url:emptyUrl,steps:[emptyParams],parameters:{},expected_outputs:{comments:'暂无评论'}});
assert.equal(textStillFails.passed,false,'empty proof must not satisfy a content text assertion');
await assert.rejects(()=>client.call('action',{task_id:emptyTask,kind:'extract',selector:'#empty-container',fields:[{name:'date',selector:'.whitespace-time',source:'attribute',attribute:'datetime',required:true}],limit:1,output_name:'dates'}),error=>error.code==='REQUIRED_FIELD_MISSING');
for(const bad of [{...emptyParams,empty_state:{selector:'.empty-state',text:'没有结果'}}, {...emptyParams,row_selector:undefined}, {...emptyParams,allow_empty:'true'},
{...batchParams,allow_empty:true,empty_state:{selector:'.empty-state',text:'暂无评论'}}]) {
await assert.rejects(()=>client.call('action',{task_id:emptyTask,...bad}),error=>['INVALID_INPUT','EMPTY_STATE_UNCONFIRMED'].includes(error.code));
}
await client.call('close',{task_id:emptyTask});
passed('唯一完整可见空态经过两次真实复核才能空预期通过;文本预期仍拒空,required空白属性拒绝');
for(const mode of ['busy','hidden','duplicate']){
const id=`empty-reject-${mode}`;
await openBatch(id,`${emptyUrl}?mode=${mode}`);
await assert.rejects(()=>client.call('action',{task_id:id,...emptyParams}),error=>error.code==='EMPTY_STATE_UNCONFIRMED');
await client.call('close',{task_id:id});
}
const realRowTask='empty-is-nonempty';
await openBatch(realRowTask,emptyUrl+'?mode=rows');
const realRows=await client.call('action',{task_id:realRowTask,...emptyParams});
assert.equal(realRows.result.count,1);
assert.equal(realRows.result.empty_state,undefined);
await client.call('close',{task_id:realRowTask});
passed('忙碌、隐藏或多重空态均不确认为零;已有真实行时按普通内容读取而不伪造空态');
const nonemptyTask='nonempty-structure-assertion';
await openBatch(nonemptyTask,actionUrl);
const nonemptyStep={kind:'extract',selector:'[data-testid="row"]',limit:2,output_name:'notes',fields:[
{name:'title',selector:'.missing-title',source:'text'}, {name:'author',selector:'.missing-author',source:'text'},
{name:'source',selector:'.source',source:'href',required:true}]};
const nonempty=await client.call('verify',{task_id:nonemptyTask,entry_url:actionUrl,steps:[nonemptyStep],parameters:{},expected_outputs:{notes:{state:'nonempty'}}});
assert.equal(nonempty.passed,true,JSON.stringify(nonempty.errors));
assert.equal(nonempty.outputs.notes.count,2);
assert.ok(nonempty.outputs.notes.items.every(item=>item.title===null && item.author===null && item.source.startsWith(actionUrl)),'only schema nonempty is asserted; optional text can be unavailable');
const actualEmpty=await client.call('verify',{task_id:nonemptyTask,entry_url:emptyUrl,steps:[emptyParams],parameters:{},expected_outputs:{comments:{state:'nonempty'}}});
assert.equal(actualEmpty.passed,false,'confirmed zero rows cannot satisfy nonempty');
const croppedOnly=await client.call('verify',{task_id:nonemptyTask,entry_url:windowUrl,parameters:{},
steps:[{kind:'extract',selector:'#partial-row',fields:clipFields,limit:1,output_name:'comments',complete_only:true}],expected_outputs:{comments:{state:'nonempty'}}});
assert.equal(croppedOnly.outputs.comments.count,0);
assert.equal(croppedOnly.outputs.comments.skipped_incomplete_count,1);
assert.equal(croppedOnly.passed,false,'a clipped-only extraction is not a nonempty result');
const textRead=await client.call('verify',{task_id:nonemptyTask,entry_url:emptyUrl,parameters:{},
steps:[{kind:'read',selector:'#empty-container',output_name:'notes'}],expected_outputs:{notes:{state:'nonempty'}}});
assert.equal(textRead.passed,false,'text reads cannot borrow a structured extraction assertion');
await assert.rejects(()=>client.call('verify',{task_id:nonemptyTask,entry_url:actionUrl,steps:[nonemptyStep],parameters:{},expected_outputs:{notes:{state:'nonempty',minimum:0}}}),error=>error.code==='INVALID_EXPECTED_OUTPUT');
await client.call('close',{task_id:nonemptyTask});
passed('nonempty仅确认合法结构化非空;零行/仅裁剪行/普通read和带额外键对象拒绝,不代替语义判断');
const realNonempty=nonempty.outputs.notes;
assert.equal(validNonemptyExtractOutput(nonemptyStep,realNonempty),true);
const corrupted=[{...realNonempty,count:1},{...realNonempty,count:true},{...realNonempty,count:1.5},{...realNonempty,truncated:'true'},
{...realNonempty,coverage:'empty'},{...realNonempty,source_url:'https://user:password@example.invalid/'},{...realNonempty,unexpected:true},
{...realNonempty,items:[{...realNonempty.items[0],source:' '},realNonempty.items[1]]},
{...realNonempty,items:[{source:realNonempty.items[0].source},realNonempty.items[1]]},
{...realNonempty,items:[{...realNonempty.items[0],title:7},realNonempty.items[1]]}];
for(const output of corrupted) assert.equal(validNonemptyExtractOutput(nonemptyStep,output),false);
assert.equal(validNonemptyExtractOutput({...nonemptyStep,limit:0},realNonempty),false,'an invalid producing contract cannot pass');
assert.equal(validNonemptyExtractOutput({...nonemptyStep,kind:'read'},realNonempty),false);
assert.equal(validNonemptyExtractOutput(batchParams,multiple.result),true);
assert.equal(validNonemptyExtractOutput(batchParams,{...multiple.result,batch_summary:{...multiple.result.batch_summary,batches_read:6}}),false);
assert.equal(validNonemptyExtractOutput(batchParams,{...multiple.result,items:multiple.result.items.map(()=>multiple.result.items[0])}),false,'duplicate business IDs invalidate a batch output');
assert.equal(validNonemptyExtractOutput(batchParams,{...multiple.result,skipped_incomplete_count:-1}),false);
passed('nonempty同时核对真实输出字段/计数/必填值/来源及批次形状,损坏结果和非法生产合同不能通过');
const bindingEntry=new URL('/fixture/note-binding',entryUrl).href;
const boundSteps=[
{kind:'fill',selector:'#binding-search',parameter:'keyword'},
{kind:'press',selector:'#binding-search',key:'Enter'},
{kind:'wait',selector:'#binding-results a.binding-title',state:'visible',min_count:1,timeout_ms:1500},
{kind:'click',selector:'#binding-results a.binding-title',target:{field:'text',parameter:'note_title'}},
{kind:'wait',selector:'#binding-detail',state:'visible',timeout_ms:1500},
{kind:'extract',selector:'#binding-detail',output_name:'note_detail',limit:1,fields:[
{name:'title',selector:'.detail-title',source:'text',required:true},{name:'body',selector:'.detail-body',source:'text',required:true},{name:'author',selector:'.detail-author',source:'text',required:true}]},
{kind:'extract',selector:'#binding-comments-root',row_selector:'.binding-comment',output_name:'comments',limit:3,complete_only:true,fields:[
{name:'comment_id',source:'attribute',attribute:'id',required:true},{name:'author',selector:'.comment-author',source:'text',required:true},{name:'body',selector:'.comment-body',source:'text',required:true}],
batch:{scroll_selector:'#binding-scroller',pixels:80,max_batches:2,dedupe_field:'comment_id'}},
];
const boundValues={steps:boundSteps,parameters:{keyword:'装修',note_title:bindingTitle},expected_outputs:{note_detail:[bindingTitle,'合成正文'],comments:{state:'nonempty'}}};
const bindingTask='verify-public-note-binding';
await openBatch(bindingTask,bindingEntry);
const readBound=(mode,noteId=bindingNoteId,extra={})=>client.call('verify',{task_id:bindingTask,entry_url:bindingEntry+'?mode='+mode,...boundValues,note_binding:{note_id:noteId},...extra});
const matched=await readBound('normal');
assert.equal(matched.passed,true,JSON.stringify(matched.errors));
assert.deepEqual(matched.note_binding_result,{note_id:bindingNoteId,href_checked:true,url_checked:true,source_url:new URL('/explore/'+bindingNoteId,entryUrl).href,title:bindingTitle});
assert.equal(matched.outputs.note_detail.source_url,matched.note_binding_result.source_url);
assert.equal(matched.outputs.comments.source_url,matched.note_binding_result.source_url);
assert.ok(matched.outputs.comments.count>0);
assert.ok(!JSON.stringify(matched.note_binding_result).includes('FIXTURE-BINDING-TOKEN'));
assert.ok(!JSON.stringify(matched).includes('FIXTURE-BINDING-HASH'));
passed('合法七步详情链唯一标题点击前后核对可信note ID,正文/评论来源及实际标题绑定输出并保留批次证据');
const beforeWrongClick=boundDetailRequests;
const wrongHref=await readBound('wrong-href');
assert.equal(wrongHref.passed,false);
assert.deepEqual(wrongHref.error_codes,['NOTE_BINDING_MISMATCH']);
assert.equal(boundDetailRequests,beforeWrongClick,'a same-title link for another ID must never be opened');
assert.deepEqual(wrongHref.outputs,{});
assert.equal(wrongHref.note_binding_result.href_checked,false);
assert.equal(wrongHref.note_binding_result.url_checked,false);
assert.equal(wrongHref.note_binding_result.title,null);
assert.ok(wrongHref.evidence.length>=5);
const redirected=await readBound('redirect');
assert.equal(redirected.passed,false);
assert.deepEqual(redirected.error_codes,['NOTE_BINDING_MISMATCH']);
assert.equal(redirected.note_binding_result.href_checked,true);
assert.equal(redirected.note_binding_result.url_checked,false);
assert.deepEqual(redirected.outputs,{});
assert.ok(redirected.evidence.length>=5);
passed('同标题另一ID在点击前拒绝且不请求详情;点击后同站ID重定向拒绝,均无正文/评论输出并保留真实失败图');
const changedTitle=await readBound('wrong-title');
assert.equal(changedTitle.passed,false);
assert.deepEqual(changedTitle.error_codes,['NOTE_BINDING_MISMATCH']);
assert.equal(changedTitle.note_binding_result.title,'同ID但标题已变化');
assert.equal(changedTitle.outputs.comments,undefined);
const drifted=await readBound('drift-scroll');
assert.equal(drifted.passed,false);
assert.deepEqual(drifted.error_codes,['NOTE_BINDING_MISMATCH']);
assert.equal(drifted.note_binding_result.url_checked,false);
assert.equal(drifted.outputs.note_detail.items[0].title,bindingTitle);
assert.equal(drifted.outputs.comments,undefined,'URL drift during comment batching cannot produce a completed comment output');
assert.ok(drifted.evidence.length>matched.evidence.length-2,'already obtained batch and actual failure page evidence are retained');
passed('实际标题改变停止后续评论;批次滚动期间URL ID漂移停止并保留此前有效正文及已得批次图');
const phoneBound=await readBound('phone',bindingPhoneId);
assert.equal(phoneBound.passed,true,JSON.stringify(phoneBound.errors));
const publicSource=new URL('/explore/'+bindingPhoneId,entryUrl).href;
assert.deepEqual(phoneBound.note_binding_result,{note_id:bindingPhoneId,href_checked:true,url_checked:true,source_url:publicSource,title:bindingTitle});
assert.equal(phoneBound.outputs.note_detail.source_url,publicSource);
assert.equal(phoneBound.outputs.comments.source_url,publicSource);
const unbound=await readBound('wrong-href',bindingNoteId,{note_binding:undefined});
assert.equal(unbound.passed,true,JSON.stringify(unbound.errors));
assert.equal(unbound.note_binding_result,undefined,'legacy unbound verify semantics remain unchanged');
passed('phone样数字的合成公共hexID按实际URL校验不误伤;显式绑定来源用canonical,旧无绑定语义保持');
const beforeBadContract=boundDetailRequests;
for(const [extra,code] of [
[{note_binding:{note_id:bindingNoteId,extra:true}},'INVALID_NOTE_BINDING'],
[{note_binding:{note_id:'invalid'}},'INVALID_NOTE_BINDING'],
[{steps:boundSteps.slice(0,4)},'NOTE_BINDING_CONTRACT'],
[{steps:boundSteps.map((step,index)=>index===1 ? {...step,selector:'#another-search'} : step)},'NOTE_BINDING_CONTRACT'],
[{steps:boundSteps.map((step,index)=>index===6 ? {...step,fields:step.fields.map(field=>field.name==='comment_id' ? {...field,attribute:'data-item-id'} : field)} : step)},'NOTE_BINDING_CONTRACT'],
]) await assert.rejects(()=>readBound('normal',bindingNoteId,extra),error=>error.code===code);
assert.equal(boundDetailRequests,beforeBadContract,'invalid binding contracts fail before navigation/click');
await client.call('close',{task_id:bindingTask});
passed('绑定参数与七步/动态标题/字段/搜索目标合同严格核对,不能借绑定执行任意click或其它读取链');
await seekRegression(client,entryUrl,passed,{bindingNoteId,bindingTitle,detailRequests:()=>boundDetailRequests});
await viewportRegression(client,entryUrl,passed);
await budgetRegression(entryUrl,home,passed,{bindingNoteId,bindingTitle});
await contentRegression(client,entryUrl,passed);
await metadataRegression(client,entryUrl,passed,home,CHANNEL,semanticChanges);
await client.call('close', { task_id: 'regression-a' });
const reopened = await client.call('open', { task_id: 'regression-c', account_key: 'test-account', entry_url: entryUrl });
assert.notEqual(reopened.session_id, opened.session_id);
assert.equal(reopened.status, 'awaiting_user');
await rejectCode('capture', { task_id: 'regression-c' }, 'AWAITING_USER');
await client.call('ready', { task_id: 'regression-c', identity_label: '隔离测试账号' });
const reused = await client.call('capture', { task_id: 'regression-c' });
assert.match(reused.text, /retained/);
const profiles = await fs.readdir(path.join(home, 'profiles'));
assert.equal(profiles.length, 1);
passed('关闭重开复用同一资料目录及 Cookie,新会话仍需人工确认');
await rejectCode('not-a-method', {}, 'UNKNOWN_METHOD');
const status = await client.call('status', { task_id: 'regression-c' });
assert.equal(status.status, 'observing');
passed('JSONL 错误响应有代码且后续请求继续正常');
await client.call('close', { task_id: 'regression-c' });
assert.equal((await client.call('status', { task_id: 'regression-c' })).status, 'closed');
passed('显式关闭释放会话,状态如实返回关闭');
await client.call('open', { task_id: 'regression-eof', account_key: 'test-account', entry_url: entryUrl });
await client.call('ready', { task_id: 'regression-eof', identity_label: '隔离测试账号' });
const interruptedVerify = client.call('verify', { task_id: 'regression-eof',
entry_url: new URL('/fixture/slow', entryUrl).href, steps,
parameters: { keyword: '木材' }, expected_outputs: { results: '木材' },
}).then(result => ({ result }), error => ({ error: error.message }));
let navigationTimer;
try {
await Promise.race([slowNavigation, new Promise((_, reject) => {
navigationTimer = setTimeout(() => reject(new Error('Verify never reached the pending local navigation')), 8_000);
})]);
} finally { clearTimeout(navigationTimer); }
const closingStarted = Date.now();
const exited = await client.close({ timeout: 8_000 });
const elapsed = Date.now() - closingStarted;
assert.deepEqual(exited, { code: 0, signal: null });
assert.ok(elapsed < 8_000, `EOF shutdown took ${elapsed} ms`);
const interrupted = await interruptedVerify;
assert.ok(interrupted.error || interrupted.result?.passed === false, 'Interrupted verify must never pass');
assert.equal(slowRequestCount, 1, 'Interrupted navigation must not be replayed');
passed('verify执行中EOF立即中断并正常退出,不等待20秒导航超时或重放', { elapsed_ms: elapsed });
restartedClient = browserClient(home);
const afterEof = await restartedClient.call('open', { task_id: 'regression-after-eof', account_key: 'test-account', entry_url: entryUrl });
assert.equal(afterEof.status, 'awaiting_user');
await restartedClient.call('ready', { task_id: 'regression-after-eof', identity_label: '隔离测试账号' });
const afterRestart = await restartedClient.call('capture', { task_id: 'regression-after-eof' });
assert.match(afterRestart.text, /retained/);
assert.equal((await fs.readdir(path.join(home, 'profiles'))).length, 1);
passed('EOF后新进程重开原资料目录,Cookie保留且浏览器锁释放');
succeeded = true;
await fs.writeFile(path.join(runRoot, 'report.json'), JSON.stringify({ passed: results.length, results }, null, 2));
console.log(JSON.stringify({ passed: results.length, browser_channel: CHANNEL, artifacts: KEEP_ARTIFACTS ? runRoot : 'removed after success' }));
} finally {
try { await client.close(); await restartedClient?.close(); }
finally { await new Promise(resolve => server.close(resolve)); }
if (succeeded && !KEEP_ARTIFACTS) {
// Only remove the exact directory created by this run under this workspace.
if (path.dirname(runRoot) !== TEMP_ROOT || !path.basename(runRoot).startsWith('worker-regression-')) {
throw new Error('Refusing to remove a path outside this test run');
}
await fs.rm(runRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 });
} else console.log(`验收证据保留在 ${runRoot}`);
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });