219 lines
16 KiB
JavaScript
219 lines
16 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';
|
|
|
|
// Every browser, port, schema, profile and HTTP page belongs to this isolated run.
|
|
const output = path.join(root, '.tmp', `discovery-acceptance-${Date.now()}`);
|
|
fs.mkdirSync(output, { recursive: true });
|
|
const escape = value => String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
|
|
const notes = [
|
|
{ title: '装修求助:新房准备装修,怎么选装修公司?', author: '合成分享作者甲', id: '1'.repeat(24) },
|
|
{ title: '装修公司案例:现代风格完工实拍', author: '合成装修工作室', id: '2'.repeat(24) },
|
|
{ title: '数码手机拍摄教程', author: '合成数码作者', id: '3'.repeat(24) },
|
|
{ title: '', author: '', id: '4'.repeat(24) },
|
|
{ title: '装修经验分享:材料选择与施工流程', author: '合成分享作者乙', id: '5'.repeat(24) },
|
|
{ title: '装修求助预算咨询', author: '无效来源样本', id: '6'.repeat(24), invalid: true },
|
|
{ title: '装修重复展示内容', author: '合成重复作者甲', id: '7'.repeat(24) },
|
|
{ title: '装修重复展示内容', author: '合成重复作者乙', id: '8'.repeat(24) },
|
|
];
|
|
let receivedWrites = 0;
|
|
let fixtureRequests = 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');
|
|
const keyword = url.searchParams.get('q') || '';
|
|
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
response.end(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>隔离内容初筛样本</title><style>body{font:16px/20px sans-serif;padding:12px;margin:0}h1{font-size:24px;line-height:30px;margin:0 0 12px}form{margin-bottom:12px}input,button{font:inherit}article{height:64px;box-sizing:border-box;padding:8px;border:1px solid #ccc;margin:2px 0}a{display:block;min-height:20px;color:inherit}.author{display:block;color:#666;font-size:13px}</style><h1>装修内容合成搜索</h1><form method="get" action="/"><label>搜索关键词<input data-testid="search-input" type="search" name="q" value="${escape(keyword)}"></label><button type="submit">搜索</button></form><section data-testid="search-results">${notes.map(note => `<article data-testid="search-row"><a class="source" href="${note.invalid ? '/not-a-note' : '/explore/' + note.id}"><span class="title">${escape(note.title)}</span><span class="author">${escape(note.author)}</span></a></article>`).join('')}</section></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 url = await listen(server); const port = Number(new URL(url).port); await new Promise(resolve => server.close(resolve)); return port; }
|
|
const apiPort = await unusedPort();
|
|
let uiPort = await unusedPort();
|
|
if (apiPort === uiPort) 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_discovery_' + 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 new Error('Owned 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 new Error('Owned 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/discovery/runs', undefined, 401);
|
|
await client.request('GET', '/api/auth/status');
|
|
await client.request('POST', '/api/auth/login', { username: 'admin', password: '123456' });
|
|
assert.equal((await client.request('GET', '/api/discovery/runs')).items.length, 0);
|
|
const frontend = launch(process.execPath, [path.join(root, 'apps/admin/node_modules/vite/bin/vite.js'), '--config', path.join(root, 'apps/admin/vite.config.ts'), '--host', '127.0.0.1', '--port', String(uiPort), '--strictPort'], 'admin');
|
|
await ready(uiBase, frontend);
|
|
browser = await chromium.launch({ headless: true, channel: environment.ASSISTANT_BROWSER_CHANNEL });
|
|
page = await browser.newPage({ viewport: { width: 1480, height: 1050 } });
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
await page.goto(uiBase);
|
|
await page.getByTestId('auth-username').fill('admin');
|
|
await page.getByTestId('auth-password').fill('123456');
|
|
await page.getByTestId('auth-submit').click();
|
|
await page.getByTestId('nav-discovery').click();
|
|
await page.getByRole('heading', { name: '内容初筛', exact: true }).waitFor();
|
|
await page.getByText('还没有符合读取形状的搜索候选', { exact: true }).waitFor();
|
|
assert.equal(await page.getByTestId('discovery-start').isDisabled(), true);
|
|
await page.screenshot({ path: path.join(output, 'empty-candidates.png'), fullPage: true });
|
|
const task = await client.request('POST', '/api/learning/tasks', { name: '隔离装修搜索结果读取', platform: 'demo', entry_url: fixtureBase, account_key: 'discovery-fixture', goal: '只读取本机合成搜索列表', parameters: { keyword: '装修' } }, 201);
|
|
await client.request('POST', `/api/learning/tasks/${task.id}/browser/open`, {});
|
|
await client.request('POST', `/api/learning/tasks/${task.id}/browser/ready`, { identity_label: '内容初筛合成维护者' });
|
|
await client.request('POST', `/api/learning/tasks/${task.id}/browser/capture`, {});
|
|
const action = data => client.request('POST', `/api/learning/tasks/${task.id}/browser/action`, data);
|
|
await action({ kind: 'fill', selector: '[data-testid="search-input"]', parameter: 'keyword' });
|
|
await action({ kind: 'press', selector: '[data-testid="search-input"]', key: 'Enter' });
|
|
await action({ kind: 'wait', selector: '[data-testid="search-row"]', state: 'visible', min_count: 1, timeout_ms: 3000 });
|
|
await action({ kind: 'extract', selector: '[data-testid="search-results"]', row_selector: '[data-testid="search-row"]', 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: '.source', source: 'href', required: true },
|
|
] });
|
|
const candidate = await client.request('POST', `/api/learning/tasks/${task.id}/candidates`, { name: '合成搜索列表候选' }, 201);
|
|
const check = await client.request('POST', `/api/learning/candidates/${candidate.id}/check`, {});
|
|
assert.equal(check.status, 'passed', JSON.stringify(check.errors));
|
|
await page.getByRole('button', { name: '刷新记录', exact: true }).click();
|
|
const form = page.getByTestId('discovery-form');
|
|
await form.getByText('合成搜索列表候选 · v1', { exact: true }).waitFor();
|
|
await page.getByText('规则初筛,模型尚未接入', { exact: true }).waitFor();
|
|
assert.equal(await form.getByLabel('服务', { exact: true }).inputValue(), '装修服务');
|
|
assert.equal(await form.getByLabel('目标客户条件', { exact: true }).inputValue(), '有装修需求的业主');
|
|
await form.getByLabel('搜索关键词', { exact: true }).fill('装修');
|
|
await form.getByLabel('服务地区(可选)', { exact: true }).fill('合成地区条件');
|
|
await form.getByLabel('最多保留笔记数', { exact: true }).fill('2');
|
|
const received = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith('/api/discovery/runs'));
|
|
await page.getByTestId('discovery-start').click();
|
|
const response = await received;
|
|
assert.equal(response.status(), 201, await response.text());
|
|
const run = await response.json();
|
|
assert.equal(run.status, 'completed', JSON.stringify(run.errors));
|
|
assert.equal(run.analysis_mode, 'rules'); assert.equal(run.model_connected, false);
|
|
assert.equal(run.conditions.region, '合成地区条件');
|
|
assert.equal(run.candidate_digest, candidate.digest);
|
|
assert.equal(run.selected.length, 2);
|
|
assert.deepEqual(run.selected.map(item => item.note_id), ['1'.repeat(24), '2'.repeat(24)]);
|
|
assert.ok(run.results.some(item => item.classification === 'peer_case' && item.selected));
|
|
assert.ok(run.results.some(item => item.classification === 'unrelated' && !item.selected));
|
|
assert.ok(run.results.some(item => item.classification === 'invalid' && !item.selected));
|
|
assert.ok(run.results.some(item => item.classification === 'ambiguous' && !item.selected));
|
|
assert.ok(run.results.every(item => item.reasons.length && item.uncertainties.length));
|
|
assert.ok(run.evidence.length);
|
|
const runView = page.getByTestId('discovery-run');
|
|
await runView.waitFor();
|
|
await runView.getByText('合成地区条件', { exact: true }).waitFor();
|
|
await runView.getByText('装修求助信号', { exact: true }).waitFor();
|
|
await runView.getByText('同行 / 案例', { exact: true }).waitFor();
|
|
assert.equal(await page.getByTestId('discovery-selected').getByRole('link', { name: '查看笔记来源', exact: true }).count(), 2);
|
|
await runView.getByRole('button', { name: /^查看证据/ }).click();
|
|
const drawer = page.getByRole('dialog', { name: '本次搜索来源与证据', exact: true });
|
|
await drawer.waitFor();
|
|
assert.ok(await drawer.locator('img').count());
|
|
await page.screenshot({ path: path.join(output, 'evidence.png'), fullPage: true });
|
|
await page.getByRole('button', { name: /关闭|close/i }).last().click();
|
|
await page.getByTestId('nav-discovery').click();
|
|
await page.getByTestId('discovery-history').getByRole('button', { name: '查看初筛', exact: true }).first().click();
|
|
await page.getByTestId('discovery-run').getByText('合成地区条件', { exact: true }).waitFor();
|
|
assert.equal((await client.request('GET', `/api/discovery/runs/${run.id}`)).candidate_digest, candidate.digest);
|
|
await page.screenshot({ path: path.join(output, 'discovery-history.png'), fullPage: true });
|
|
await client.request('POST', `/api/learning/tasks/${task.id}/browser/close`, {});
|
|
await page.getByTestId('nav-discovery').click();
|
|
await page.getByTestId('discovery-form').getByText('合成搜索列表候选 · v1', { exact: true }).waitFor();
|
|
const countBefore = fixtureRequests;
|
|
const failureResponse = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith('/api/discovery/runs'));
|
|
await page.getByTestId('discovery-start').click();
|
|
const failedResponse = await failureResponse;
|
|
assert.equal(failedResponse.status(), 201);
|
|
const failed = await failedResponse.json();
|
|
assert.equal(failed.status, 'failed'); assert.equal(failed.validation_id, null);
|
|
assert.ok(failed.error_codes.length); assert.equal(fixtureRequests, countBefore, 'A missing session must not trigger an automatic browser login or search');
|
|
await page.getByTestId('discovery-errors').waitFor();
|
|
await page.getByTestId('discovery-run').getByRole('button', { name: '前往学习会话', exact: true }).waitFor();
|
|
await page.screenshot({ path: path.join(output, 'session-required.png'), fullPage: true });
|
|
await stop(backend);
|
|
assert.equal(backend.exitCode, 0);
|
|
backend = launch(pythonPath(), backendArgs, 'server-restart');
|
|
await ready(apiBase, backend);
|
|
const restored = await client.request('GET', `/api/discovery/runs/${run.id}`);
|
|
assert.equal(restored.candidate_digest, run.candidate_digest);
|
|
assert.deepEqual(restored.selected, run.selected);
|
|
assert.deepEqual(restored.conditions, run.conditions);
|
|
const history = await client.request('GET', '/api/discovery/runs');
|
|
assert.equal(history.items.length, 2);
|
|
await page.goto(`${uiBase}/#/discovery/${run.id}`);
|
|
await page.getByTestId('discovery-run').getByText('合成地区条件', { exact: true }).waitFor();
|
|
assert.equal(receivedWrites, 0);
|
|
assert.deepEqual(errors, []);
|
|
const summary = { result: 'passed', task_id: task.id, candidate_id: candidate.id, run_id: run.id, validation_id: run.validation_id,
|
|
empty_candidate_guidance: true, rules_disclosed: true, model_connected: false, conditions_saved: true,
|
|
max_selected_enforced: true, peer_case_retained: true, transparent_reasons: true, history_reopened: true,
|
|
evidence_opened: true, failure_saved_without_auto_login: true, restart_persistence: true,
|
|
results: run.results.length, selected: run.selected.length, persisted_runs: history.items.length, received_writes: receivedWrites, errors };
|
|
fs.writeFileSync(path.join(output, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
console.log(JSON.stringify({ ...summary, output }));
|
|
} catch (error) {
|
|
if (page && !page.isClosed()) await page.screenshot({ path: path.join(output, 'failure.png'), fullPage: true }).catch(() => {});
|
|
console.error('Discovery 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));
|
|
}
|