132 lines
14 KiB
JavaScript
132 lines
14 KiB
JavaScript
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 = `<section data-testid="note-detail"><h1 class="note-title">${escape(note.title)}</h1><p class="note-body">${escape(note.body)}</p><p class="note-author">${escape(note.author)}</p></section><section data-testid="comments-root" style="height:280px;overflow:auto;border:1px solid;padding:8px;box-sizing:border-box">${[1, 2, 3].map(index => `<article class="top-level-comment" id="comment-${note.id[0]}-${index}" style="height:130px;box-sizing:border-box;padding:10px;margin:0"><span class="comment-author">合成评论作者${index}</span>${note.id === notes[1].id ? '' : `<p class="comment-body">首层合成评论${index},只反映原文。</p>`}${index === 1 ? '<div class="reply" id="reply-excluded">已展示回复,未作为首层读取</div>' : ''}</article>`).join('')}</section>`;
|
|
} else {
|
|
content = `<h1>隔离装修搜索</h1><form method="get" action="/"><label>搜索关键词<input data-testid="search-input" type="search" name="q" value="${escape(url.searchParams.get('q') || '')}"></label><button type="submit">搜索</button></form><section data-testid="search-results">${notes.map((note, index) => `<article class="note-card" data-testid="search-row"><a data-testid="note-link" class="title" href="/explore/${tamper && index === 0 ? wrongId : note.id}" ${tamper && index === 0 ? `onclick="document.getElementById('wrong-click-count').textContent=String(Number(document.getElementById('wrong-click-count').textContent)+1)"` : ''}>${escape(note.title)}</a><span class="author">${escape(note.author)}</span></article>`).join('')}</section><p>错对象点击计数:<span id="wrong-click-count">0</span></p>`;
|
|
}
|
|
response.end(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>隔离单篇读取样本</title><style>body{font:16px/22px sans-serif;padding:18px;margin:0}h1{font-size:23px;line-height:30px;margin:0 0 12px}form{margin:12px 0}a{display:block;padding:6px;color:#14695e}input,button{font:inherit}article{border-bottom:1px solid #ccc;padding:8px;margin:4px 0}.author{display:block}.comment-body{margin:8px 0}.reply{font-size:12px;color:#777}</style><main>${content}</main></html>`);
|
|
});
|
|
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));}
|