1351 lines
112 KiB
JavaScript
1351 lines
112 KiB
JavaScript
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(`<!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)}&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; });
|