assistant/tools/learning/test-wait-preflight.mjs

143 lines
9.1 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
import { READONLY_POLICY_VERSION, endPlatformRequestPolicy, matchPlatformRequest } from './platform-policy.mjs';
// Official-origin cases are pure helper calls with synthetic metadata. The only
// browser entry below is a new isolated profile on our own loopback HTTP page.
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
await fs.mkdir(path.join(root,'.tmp'),{recursive:true});
const output = await fs.mkdtemp(path.join(root, '.tmp', 'wait-preflight-'));
process.env.ASSISTANT_LEARNING_HOME = path.join(output, 'learning');
process.env.ASSISTANT_BROWSER_HEADLESS = '1';
process.env.ASSISTANT_BROWSER_CHANNEL = process.platform === 'win32' ? 'msedge' : 'chromium';
const { preflightReadonlyWait, dispatch } = await import('./browser-worker.mjs');
const official = 'https://www.xiaohongshu.com';
const pageUrl = official + '/search_result_ai?keyword=fixture';
const ordinary = { tag:'a', readonly_root:true, sensitive:false, forbidden:false, visible:true, in_viewport:true };
const base = { sessionOrigin:official, pageUrl, kind:'wait', selector:'#global a.title', selectorObserved:true,
observed:{kind:'click',tag:'a',unique:false,sensitive:false}, actualRoots:[{...ordinary},{...ordinary}], now:10000 };
const request = { automating:true, sessionOrigin:official, pageUrl, activeMainFrame:true, method:'POST',
resourceType:'xhr', url:'https://t2.xiaohongshu.com/api/v2/collect', now:10001 };
let assertions = 0;
let officialCases = 0;
const equal = (actual, expected, message) => { assert.deepEqual(actual, expected, message); assertions += 1; };
const allowed = (policy, rule) => ({policy_version:READONLY_POLICY_VERSION,policy_rule:rule});
function rejected(patch, code = 'READONLY_WAIT_NOT_READY') {
let returned;
const context = {...base,...patch};
const before = JSON.stringify(context);
assert.throws(() => { returned = preflightReadonlyWait(context); }, error => error.code === code); assertions += 1;
equal(returned, undefined, 'preflight failure never returns an active capability');
equal(JSON.stringify(context), before, 'preflight does not mutate caller state or start an action');
officialCases += 1;
}
// Empty current collection and a visible but unobserved collection are two
// distinct missing preconditions. Neither may borrow the previous search scope.
for (const patch of [
{actualRoots:[]}, {actualRoots:[],selectorObserved:false},
{selectorObserved:false}, {selectorObserved:undefined}, {selectorObserved:null},
{actualRoots:null}, {actualRoots:undefined}, {actualRoots:[null]},
{actualRoots:[{...ordinary,readonly_root:false}]},
{actualRoots:[{...ordinary,readonly_root:undefined}]},
{selector:''}, {selector:'body',actualRoots:[ordinary]},
{pageUrl:'https://example.invalid/search_result'},
{actualRoots:[{...ordinary,tag:'input'}]},
]) rejected(patch);
for (const patch of [
{actualRoots:[{...ordinary,sensitive:true}]},
{actualRoots:[{...ordinary,forbidden:true}]},
{actualRoots:[ordinary,{...ordinary,sensitive:true}]},
{actualRoots:[{...ordinary,sensitive:true}],selectorObserved:false},
]) rejected(patch,'SENSITIVE_OUTPUT');
const unchanged = JSON.stringify(base);
const policy = preflightReadonlyWait(base); officialCases += 1;
equal(policy?.scope,'readonly_ui');
equal(policy?.version,READONLY_POLICY_VERSION);
equal(JSON.stringify(base),unchanged);
equal(matchPlatformRequest({...request,policy}),allowed(policy,'collect'));
equal(matchPlatformRequest({...request,policy,url:'https://apm-fe.xiaohongshu.com/api/data',resourceType:'fetch'}),allowed(policy,'apm_data'));
for (const patch of [
{url:'https://apm-fe.xiaohongshu.com/api/data',resourceType:'xhr'},
{url:'https://as.xiaohongshu.com/api/sec/v1/shield/webprofile'},
{url:'https://so.xiaohongshu.com/api/sns/web/v2/search/notes'},
{url:'https://example.invalid/unknown-write'},
{url:'https://edith.xiaohongshu.com/api/sns/web/v2/comment/post'},
{activeMainFrame:false}, {method:'PUT'}, {now:25001},
]) equal(matchPlatformRequest({...request,policy,...patch}),null);
endPlatformRequestPolicy(policy);
equal(matchPlatformRequest({...request,policy}),null,'returned wait policy is revoked with the existing end path');
equal(matchPlatformRequest({...request,policy:{...policy}}),null,'a copied policy is never an issued capability');
const bodyPolicy = preflightReadonlyWait({...base,selector:'body',selectorObserved:false,actualRoots:[{...ordinary,tag:'body'}]}); officialCases += 1;
equal(bodyPolicy?.scope,'readonly_ui','only the existing wait body exception is retained');
equal(matchPlatformRequest({...request,policy:bodyPolicy}),allowed(bodyPolicy,'collect'));
endPlatformRequestPolicy(bodyPolicy);
equal(matchPlatformRequest({...request,policy:bodyPolicy}),null);
for (const kind of ['fill','press','click','read','extract','scroll']) equal(preflightReadonlyWait({...base,kind}),null);
for (const sessionOrigin of ['http://127.0.0.1:8765','https://example.invalid','https://www.xiaohongshu.com.evil.invalid']) {
equal(preflightReadonlyWait({...base,sessionOrigin,actualRoots:[],selectorObserved:false}),null,'non-official behavior is unchanged');
}
let pendingRelease;
let fixtureRequests = 0;
let fixtureWrites = 0;
const server = createServer((req,res) => {
fixtureRequests += 1;
if (req.method !== 'GET') { fixtureWrites += 1; res.writeHead(405).end(); return; }
const url = new URL(req.url,'http://127.0.0.1');
if (url.pathname === '/release') { pendingRelease = res; return; }
if (url.pathname !== '/') { res.writeHead(404).end(); return; }
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});
res.end(`<!doctype html><html><head><title>隔离等待前置回归</title><style>body{font:18px sans-serif;margin:20px}.title{display:block;margin:12px}</style></head><body><p id="status">本机等待样本</p><section id="global">结果稍后出现</section><script>
fetch('/release').then(r=>r.text()).then(()=>{document.querySelector('#global').insertAdjacentHTML('beforeend','<a class="title" href="/?item=1">合成标题甲</a><a class="title" href="/?item=2">合成标题乙</a>')});
</script></body></html>`);
});
let opened = false;
let listening = false;
try {
await new Promise((resolve,reject) => { server.once('error',reject); server.listen(0,'127.0.0.1',resolve); }); listening = true;
const entry = `http://127.0.0.1:${server.address().port}/`;
const taskId = 'wait-preflight-demo';
await dispatch('open',{task_id:taskId,account_key:'isolated-wait-preflight',entry_url:entry}); opened = true;
await dispatch('ready',{task_id:taskId,identity_label:'隔离等待测试'});
const before = await dispatch('capture',{task_id:taskId});
equal(before.elements.some(element=>element.selector==='#global a.title'),false,'latest capture does not invent a zero-item collection');
assert.ok(before.elements.some(element=>element.selector==='#global')); assertions += 1;
for (let index=0;!pendingRelease && index<100;index+=1) await delay(10);
assert.ok(pendingRelease,'fixture owns the pending loopback GET before testing the wait'); assertions += 1;
let settled = false;
const waiting = dispatch('action',{task_id:taskId,kind:'wait',selector:'#global a.title',state:'visible',min_count:2,timeout_ms:4000});
void waiting.then(()=>{settled=true;},()=>{settled=true;});
await delay(300);
equal(settled,false,'unchanged demo wait does not fail on an initially missing collection');
pendingRelease.writeHead(200,{'Content-Type':'text/plain'}).end('release'); pendingRelease = null;
const result = await waiting;
equal(result.result.satisfied,true);
equal(result.result.min_count,2);
assert.ok(result.after.elements.some(element=>element.selector==='#global a.title' && element.count===2)); assertions += 1;
for (const evidence of [result.before,result.after]) {
const png = await fs.readFile(evidence.screenshot_path); equal(png.subarray(1,4).toString(),'PNG');
}
equal(result.after.request_observations.filter(item=>item.blocked).length,0);
equal(fixtureWrites,0);
const summary = {result:'passed',assertions,official_preflight_cases:officialCases,official_case_environment:'pure helper only',
official_browser_navigation:false,live_platform_requests:0,application_api_calls:0,model_calls:0,
empty_or_unobserved_precondition_rejected:true,sensitive_precondition_distinct:true,old_readonly_rules_only:true,
copied_or_ended_policy_rejected:true,caller_state_unchanged:true,
loopback_edge_demo_wait_passed:true,initially_unobserved_collection:true,actual_pngs:true,
fixture_requests:fixtureRequests,fixture_writes:fixtureWrites,production_official_wiring:'reviewed separately; not official browser end-to-end'};
await fs.writeFile(path.join(output,'summary.json'),JSON.stringify(summary,null,2));
console.log(JSON.stringify({...summary,output}));
} finally {
if (opened) await dispatch('close',{task_id:'wait-preflight-demo'}).catch(()=>{});
if (pendingRelease) pendingRelease.end('closed');
if (listening) await new Promise(resolve=>server.close(resolve));
}