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 => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[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(`
已经读取的真实内容
`);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(`总预算隔离样本已经读取的真实内容
${request.headers.cookie?.includes('budget-session=retained') ? 'retained' : 'fresh'}
等待文本
`);
return;
}
if(url.pathname==='/fixture/budget-binding'){
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`预算绑定样本`);
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(`绑定笔记搜索`);
return;
}
if(url.pathname==='/fixture/seek-target'){
const mode=url.searchParams.get('mode') || 'offscreen',title=mode==='unsafe' ? '发送' : '目标笔记';
const anchor=`${title}`;
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`有界目标定位${mode==='visible' || mode==='obscured' ? '' : '
前置内容
'}${mode==='missing' || mode==='time' || mode==='lazy' ? '' : anchor}${mode==='duplicate' ? anchor : ''}
目标笔记实际定位样本
${mode==='obscured' ? '遮挡
' : ''}`);
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(``);
response.end(`绑定笔记详情${mode==='wrong-title' ? '同ID但标题已变化' : bindingTitle}
合成正文,仅用于所选笔记读取测试
合成笔记作者${mode==='drift-scroll' ? `` : ''}`);
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(`搜索固定语义
${escapeHtml(url.searchParams.get('keyword') || '初始')} 的结果
`);
return;
}
if(url.pathname==='/fixture/read-window'){
const mode=url.searchParams.get('mode') || 'base';
const row=(name,id)=>``;
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`长文与完整字段${mode==='lazy' ? '
长正文
' : `${mode==='long' ? '
长正文
' : ''}${row('甲','first-row')}${row('乙','partial-row')}${row('丙','third-row')}`}
${mode==='lazy' ? `` : ''}`);
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(`有界评论批次${ids.map(id=>`
作者${id}正文${id}
`).join('')}
`);
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(`明确空态暂无评论
${mode==='duplicate' ? '暂无评论
' : ''}${mode==='rows' ? '真实评论' : ''}时间`);
return;
}
if(url.pathname==='/fixture/delayed-collection'){
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`集合延迟加载`);
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(`受限动作测试受限动作样本
${order.map(id => `笔记 ${id}`).join(' ')}
当前 ${escapeHtml(url.searchParams.get('selected') || '无')}
等待删除
尚未尝试
下方笔记
`);
return;
}
if (url.pathname === '/fixture/viewport') {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`视口候选
${Array.from({length:140}, (_, i) => `
下方 ${i}`).join(' ')}
非真人操作提醒隐藏文本
${Array.from({length:120},(_,i)=>`
可见链接${i}`).join(' ')}
`);
return;
}
if (url.pathname === '/fixture/popups') {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`弹窗搜索列表
笔记详情
旧页可关闭的笔记详情
`);
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(`人工阶段页面等待人工确认
`);
return;
}
if (url.pathname === '/fixture/discovery') {
const duplicate = url.searchParams.has('duplicate');
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`实际可读结构发现样本笔记标题
笔记作者
样本正文:装修材料需求
隐藏内部提示VISIBLE-CREDENTIAL-SECRET
${['alpha','beta'].map((id,i)=>``).join('')}
${['alpha','beta'].map((id,i)=>`${'
'.repeat(9)}`).join('')}
当前 ${escapeHtml(url.searchParams.get('selected') || '无')}
`);
return;
}
if (url.pathname === '/fixture/popup-detail') {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`同源笔记详情实际笔记内容
旧页状态待核对
`);
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(`本机学习工作进程验收
材料搜索
${keyword} 的搜索结果
${request.headers.cookie?.includes('worker-test-session=retained') ? 'retained' : 'fresh'}
13812345678
`);
});
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; });