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 => `
合成评论作者${index}${note.id === notes[1].id ? '' : `

首层合成评论${index},只反映原文。

`}${index === 1 ? '
已展示回复,未作为首层读取
' : ''}
`).join('')}
`; } else { content = `

隔离装修搜索

${notes.map((note, index) => `
${escape(note.title)}${escape(note.author)}
`).join('')}

错对象点击计数: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));}