import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { chromium } from 'playwright';
import { pythonPath, root } from './server.mjs';
import { stopOwnedChild } from './processes.mjs';
// This script owns every process, schema, port, browser and synthetic note.
const output = path.join(root, '.tmp', `route-acceptance-${Date.now()}`);
fs.mkdirSync(output, { recursive: true });
const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
const notes = [
{ id: 'a'.repeat(24), title: '装修求助:新房准备装修,怎么选择施工方案?', author: '合成作者甲', body: '合成正文甲:准备装修,需要核对施工方案。' },
{ id: 'b'.repeat(24), title: '装修公司案例:现代风格完工实拍', author: '合成装修工作室', body: '合成正文乙:仅为案例分享,不能认定采购需求。' },
];
const wrongId = 'c'.repeat(24);
const visits = new Map();
let tamper = false;
let activeKeyword = '示例';
let fixtureRequests = 0;
let receivedWrites = 0;
const fixture = createServer((request, response) => {
fixtureRequests += 1;
if (request.method !== 'GET') receivedWrites += 1;
const url = new URL(request.url, 'http://127.0.0.1');
if (url.searchParams.has('q')) activeKeyword = url.searchParams.get('q');
const notes = [
{id:'a'.repeat(24),title:activeKeyword+'资料甲',body:'本次关键词:'+activeKeyword,author:'合成作者甲'},
{id:'b'.repeat(24),title:activeKeyword+'资料乙',body:'第二篇:'+activeKeyword,author:'合成作者乙'},
];
const match = url.pathname.match(/^\/explore\/([a-f0-9]{24})$/);
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
let content;
if (match) {
visits.set(match[1], (visits.get(match[1]) || 0) + 1);
const note = notes.find(item => item.id === match[1]) || { id: wrongId, title: notes[0].title, author: '错来源作者', body: '禁止当作目标正文的错来源内容' };
content = `${escape(note.title)}
${escape(note.body)}
${escape(note.author)}
${[1, 2, 3].map(index => ``).join('')}`;
} else {
content = `
隔离装修搜索
错对象点击计数:0
`;
}
response.end(`隔离单篇读取样本${content}`);
});
async function listen(server) { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); return `http://127.0.0.1:${server.address().port}`; }
async function unusedPort() { const server = createServer(); const base = await listen(server); await new Promise(resolve => server.close(resolve)); return Number(new URL(base).port); }
const apiPort = await unusedPort();
let uiPort = await unusedPort();
if (uiPort === apiPort) uiPort = await unusedPort();
const apiBase = `http://127.0.0.1:${apiPort}`;
const uiBase = `http://127.0.0.1:${uiPort}`;
const environment = { ...process.env, ASSISTANT_DATABASE_URL: process.env.ASSISTANT_TEST_DATABASE_URL || '',
ASSISTANT_DATABASE_SCHEMA: 'assistant_learning_reading_' + randomUUID().replaceAll('-', ''),
ASSISTANT_LEARNING_HOME: path.join(output, 'learning'), PYTHONUTF8: '1', ASSISTANT_NODE: process.execPath,
ASSISTANT_ALLOWED_ORIGINS: uiBase, ASSISTANT_API_TARGET: apiBase, ASSISTANT_BROWSER_HEADLESS: '1',
ASSISTANT_BROWSER_CHANNEL: process.env.ASSISTANT_TEST_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : 'chromium'), ASSISTANT_INSTANCE_ID: randomUUID() };
const children = new Set();
const errors = [];
let browser;
let page;
let fixtureStarted = false;
function launch(command, args, name) {
const child = spawn(command, args, { cwd: root, env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
child.assistantGraceful = name.startsWith('server'); children.add(child);
const log = fs.createWriteStream(path.join(output, name + '.log')); child.stdout.pipe(log); child.stderr.pipe(log);
child.on('error', error => errors.push(`${name}: ${error.message}`)); return child;
}
async function stop(child) { await stopOwnedChild(child, { graceful: child.assistantGraceful }); children.delete(child); }
async function ready(base, child) {
for (let index = 0; index < 100; index += 1) {
if (child.exitCode !== null) throw Error('Owned reading fixture service exited');
try { const response = await fetch(base + '/api/health', { signal: AbortSignal.timeout(1500) }); if (response.ok && (await response.json()).instance_id === environment.ASSISTANT_INSTANCE_ID) return; } catch {}
await delay(250);
}
throw Error('Owned reading fixture service was not ready');
}
class Client {
cookies = new Map(); csrf = '';
async request(method, route, body, expected = 200) {
const response = await fetch(apiBase + route, { method, redirect: 'error', signal: AbortSignal.timeout(120000), headers: {
Accept: 'application/json', Cookie: [...this.cookies].map(([key, value]) => `${key}=${value}`).join('; '), Origin: apiBase,
...(body === undefined ? {} : { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf }) }, body: body === undefined ? undefined : JSON.stringify(body) });
for (const cookie of response.headers.getSetCookie()) { const [pair] = cookie.split(';', 1); const at = pair.indexOf('='); this.cookies.set(pair.slice(0, at), pair.slice(at + 1)); }
const data = await response.json(); assert.equal(response.status, expected, `${method} ${route}: ${JSON.stringify(data)}`);
if (data.csrf_token) this.csrf = data.csrf_token; return data;
}
}
try {
const fixtureBase = await listen(fixture); fixtureStarted = true;
const backendArgs = ['-m','scripts.serve','--port',String(apiPort)];
let backend=launch(pythonPath(),backendArgs,'server');await ready(apiBase,backend);
const client=new Client();await client.request('GET','/api/auth/status');await client.request('POST','/api/auth/login',{username:'admin',password:'123456'});
const task=await client.request('POST','/api/learning/tasks',{name:'通用搜索详情评论链路',platform:'demo',entry_url:fixtureBase,account_key:'route-fixture',goal:'同一执行器学习和换参验证',parameters:{keyword:'示例',note_title:'示例资料甲'}},201);
const taskPath='/api/learning/tasks/'+task.id;
await client.request('POST',taskPath+'/browser/open',{});await client.request('POST',taskPath+'/browser/ready',{identity_label:'合成维护者'});await client.request('POST',taskPath+'/browser/capture',{});
const act=data=>client.request('POST',taskPath+'/browser/action',data);
async function search(){await act({kind:'fill',selector:'[data-testid="search-input"]',parameter:'keyword'});await act({kind:'press',selector:'[data-testid="search-input"]',key:'Enter'});await act({kind:'wait',selector:'[data-testid="note-link"]',state:'visible',min_count:1,timeout_ms:3000});}
await search();await act({kind:'extract',selector:'[data-testid="search-results"]',row_selector:'.note-card',output_name:'search_results',complete_only:true,limit:10,fields:[{name:'title',selector:'.title',source:'text',required:false},{name:'author',selector:'.author',source:'text',required:false},{name:'url',selector:'.title',source:'href',required:true}]});
const frozenSearchSteps=(await client.request('GET',taskPath)).steps.map(x=>x.id);
async function freeze(name,step_ids){const c=await client.request('POST',taskPath+'/candidates',{name,step_ids},201);assert.equal((await client.request('POST','/api/learning/candidates/'+c.id+'/check',{})).status,'passed');return c;}
const s=await freeze('通用搜索候选',frozenSearchSteps);
await search();await act({kind:'click',selector:'[data-testid="note-link"]',target:{field:'text',parameter:'note_title'}});await act({kind:'wait',selector:'[data-testid="note-detail"]',state:'visible',timeout_ms:3000});
await act({kind:'extract',selector:'[data-testid="note-detail"]',output_name:'note_detail',limit:1,fields:[{name:'title',selector:'.note-title',source:'text',required:true},{name:'body',selector:'.note-body',source:'text',required:true},{name:'author',selector:'.note-author',source:'text',required:true}]});
await act({kind:'extract',selector:'[data-testid="comments-root"]',row_selector:'.top-level-comment',output_name:'comments',complete_only:true,limit:5,batch:{scroll_selector:'[data-testid="comments-root"]',pixels:130,max_batches:2,dedupe_field:'comment_id'},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}]});
const frozenDetailSteps=(await client.request('GET',taskPath)).steps.slice(4).map(x=>x.id),d=await freeze('通用详情评论候选',frozenDetailSteps);
const routeInput={platform:'demo',task_type:'search_note_comments',search_candidate_id:s.id,detail_candidate_id:d.id};
const plan=await client.request('POST','/api/learning/routes/resolve',routeInput);
assert.equal(plan.search.digest,s.digest);assert.equal(plan.detail.digest,d.digest);assert.equal(plan.published,false);
const input={...routeInput,search_digest:s.digest,detail_digest:d.digest};
const runs=[];
for(const keyword of ['露营清单','咖啡设备']){
const r=await client.request('POST','/api/learning/routes/verify',{...input,keyword},201);
assert.equal(r.route_status,'completed',JSON.stringify(r.errors));assert.equal(r.status,'passed');assert.equal(r.model_calls,0);assert.equal(r.published,false);
assert.equal(r.usable_outputs.note_detail.items[0].body,'本次关键词:'+keyword);assert.equal(r.usable_outputs.comments.count,3);
assert.ok(r.search_validation_id&&r.detail_validation_id);runs.push(r);
}
const partial=await client.request('POST','/api/learning/routes/verify',{...input,keyword:'摄影器材',note_id:'b'.repeat(24)},201);
assert.equal(partial.route_status,'partial');assert.equal(partial.usable_outputs.note_detail.items[0].body,'第二篇:摄影器材');assert.ok(!partial.usable_outputs.comments);
const beforeClosed=fixtureRequests;await client.request('POST',taskPath+'/browser/close',{});
const failed=await client.request('POST','/api/learning/routes/verify',{...input,keyword:'没有会话'},201);assert.equal(failed.route_status,'failed');assert.equal(failed.search_validation_id,null);assert.equal(fixtureRequests,beforeClosed);
await stop(backend);assert.equal(backend.exitCode,0);backend=launch(pythonPath(),backendArgs,'server-restart');await ready(apiBase,backend);
const history=(await client.request('GET','/api/learning/validations')).items;
for(const r of [...runs,partial,failed])assert.deepEqual(history.find(v=>v.id===r.id),r);
assert.deepEqual((await client.request('GET','/api/learning/candidates/'+s.id)).manifest,s.manifest);assert.deepEqual((await client.request('GET','/api/learning/candidates/'+d.id)).manifest,d.manifest);
assert.equal(receivedWrites,0);assert.deepEqual(errors,[]);
const summary={result:'passed',database:environment.ASSISTANT_DATABASE_URL?'postgresql':'sqlite',different_keywords:runs.map(x=>x.keyword),successful_runs:runs.length,partial_body_preserved:true,no_session_no_actions:true,immutable_candidates:true,restart_history:true,model_calls:0,platform_calls:0,fixture_writes:receivedWrites,output};
fs.writeFileSync(path.join(output,'summary.json'),JSON.stringify(summary,null,2));console.log(JSON.stringify(summary));
}catch(error){console.error('Route acceptance artifacts: '+output);throw error;}
finally{if(browser)await browser.close().catch(()=>{});for(const child of [...children])await stop(child);if(fixtureStarted)await new Promise(resolve=>fixture.close(resolve));}