assistant/scripts/test-reading.mjs

212 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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', `reading-acceptance-${Date.now()}`);
fs.mkdirSync(output, { recursive: true });
const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 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');
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' });
async function task(name, account, parameters) {
const created = await client.request('POST', '/api/learning/tasks', { name, platform: 'demo', entry_url: fixtureBase, account_key: account, goal: '仅隔离合成单篇读取', parameters }, 201);
await client.request('POST', `/api/learning/tasks/${created.id}/browser/open`, {});
await client.request('POST', `/api/learning/tasks/${created.id}/browser/ready`, { identity_label: '阅读合成维护者' });
await client.request('POST', `/api/learning/tasks/${created.id}/browser/capture`, {});
return created;
}
const searchTask = await task('合成四步搜索', 'reading-search-fixture', { keyword: '装修' });
async function searchActions(taskId) {
const act = body => client.request('POST', `/api/learning/tasks/${taskId}/browser/action`, body);
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 });
return act;
}
const searchAction = await searchActions(searchTask.id);
await searchAction({ 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 },
] });
async function freeze(taskId, name) {
const candidate = await client.request('POST', `/api/learning/tasks/${taskId}/candidates`, { name }, 201);
const check = await client.request('POST', `/api/learning/candidates/${candidate.id}/check`, {});
assert.equal(check.status, 'passed', JSON.stringify(check.errors)); return candidate;
}
const search = await freeze(searchTask.id, '阅读夹具搜索候选');
const runBody = { search_candidate_id: search.id, keyword: '装修', service: '装修服务', target_customer: '有装修需求的业主', region: '', max_selected: 2 };
const run = await client.request('POST', '/api/discovery/runs', runBody, 201);
assert.equal(run.status, 'completed', JSON.stringify(run.errors)); assert.equal(run.selected.length, 2);
const runSnapshot = JSON.stringify(run);
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}/#/discovery/${run.id}`); await page.getByTestId('auth-username').fill('admin'); await page.getByTestId('auth-password').fill('123456'); await page.getByTestId('auth-submit').click();
await page.getByText('尚无同站详情读取候选', { exact: true }).waitFor();
assert.equal(await page.getByRole('button', { name: '读取正文与评论', exact: true }).first().isDisabled(), true);
const detailTask = await task('合成七步正文首层评论', 'reading-detail-fixture', { keyword: '装修', note_title: notes[0].title });
const detailAction = await searchActions(detailTask.id);
await detailAction({ kind: 'click', selector: '[data-testid="note-link"]', target: { field: 'text', parameter: 'note_title' } });
await detailAction({ kind: 'wait', selector: '[data-testid="note-detail"]', state: 'visible', timeout_ms: 3000 });
await detailAction({ 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 detailAction({ 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 detail = await freeze(detailTask.id, '合成七步详情候选');
assert.equal(detail.manifest.steps.length, 7);
await page.getByRole('button', { name: '刷新记录', exact: true }).click();
await page.getByText('合成七步详情候选 · v1', { exact: true }).waitFor();
async function readViaUI(noteId, expectBusy = false) {
const received = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/api/discovery/runs/${run.id}/readings`));
await page.locator(`[data-testid="discovery-selected-note"][data-note-id="${noteId}"]`).getByRole('button', { name: '读取正文与评论', exact: true }).click();
if (expectBusy) assert.equal(await page.getByRole('button', { name: '读取正文与评论', exact: true }).last().isDisabled(), true);
const response = await received; assert.equal(response.status(), 201, await response.text());
assert.deepEqual(response.request().postDataJSON(), { detail_candidate_id: detail.id, note_id: noteId });
const reading = await response.json();
await page.getByTestId('discovery-reading-detail').getByText(reading.id, { exact: true }).waitFor();
return reading;
}
const first = await readViaUI(notes[0].id, true);
assert.equal(first.status, 'completed', JSON.stringify(first.errors)); assert.equal(first.model_connected, false);
assert.equal(first.detail.items[0].body, notes[0].body); assert.equal(first.detail.items[0].title, notes[0].title);
assert.equal(first.note_binding_result.href_checked, true); assert.equal(first.note_binding_result.url_checked, true); assert.equal(first.note_binding_result.note_id, notes[0].id);
assert.equal(first.comments.count, 3); assert.equal(first.comments.batch_summary.batches_read, 2);
assert.ok(first.comments.items.every(item => item.comment_id.startsWith('comment-a-'))); assert.ok(!JSON.stringify(first.comments.items).includes('reply-excluded'));
await page.getByTestId('reading-body').getByText(notes[0].body, { exact: true }).waitFor();
await page.getByTestId('reading-binding').getByText('点击前来源', { exact: true }).waitFor();
const firstHash = `#/discovery/${run.id}?reading=${first.id}`;
assert.equal(new URL(page.url()).hash, firstHash);
await page.reload(); await page.getByTestId('discovery-reading-detail').getByText(first.id, { exact: true }).waitFor();
await page.goBack(); await page.waitForURL(`${uiBase}/#/discovery/${run.id}`); assert.equal(await page.getByTestId('discovery-reading-detail').count(), 0);
await page.goForward(); await page.waitForURL(`${uiBase}/${firstHash}`); await page.getByTestId('reading-body').getByText(notes[0].body, { exact: true }).waitFor();
await page.getByTestId('discovery-reading-detail').getByRole('button', { name: /^查看阅读证据/ }).click();
const evidenceDrawer = page.getByRole('dialog', { name: '本次阅读证据', exact: true }); await evidenceDrawer.waitFor(); assert.ok(await evidenceDrawer.locator('img').count());
await page.screenshot({ path: path.join(output, 'reading-evidence.png'), fullPage: true }); await page.getByRole('button', { name: /关闭|close/i }).last().click();
const second = await readViaUI(notes[1].id);
assert.equal(second.status, 'partial', JSON.stringify(second.errors)); assert.equal(second.detail.items[0].body, notes[1].body); assert.equal(second.comments, null);
assert.ok(second.error_codes.length); await page.getByTestId('reading-body').getByText(notes[1].body, { exact: true }).waitFor();
await page.getByText('读取部分完成,已有正文保留', { exact: true }).waitFor(); await page.screenshot({ path: path.join(output, 'partial-reading.png'), fullPage: true });
tamper = true;
const blocked = await readViaUI(notes[0].id);
assert.equal(blocked.status, 'failed'); assert.equal(blocked.detail, null); assert.equal(blocked.comments, null); assert.equal(visits.get(wrongId) || 0, 0);
const afterBlocked = await client.request('GET', `/api/learning/tasks/${detailTask.id}`);
assert.match(afterBlocked.observations.at(-1).text, /错对象点击计数[::]\s*0/);
assert.equal(await page.getByTestId('reading-body').count(), 0);
tamper = false;
await client.request('POST', `/api/learning/tasks/${detailTask.id}/browser/close`, {});
const countBefore = fixtureRequests;
const notReady = await readViaUI(notes[1].id);
assert.equal(notReady.status, 'failed'); assert.equal(notReady.validation_id, null); assert.equal(fixtureRequests, countBefore);
await page.getByTestId('discovery-reading-detail').getByRole('button', { name: '前往详情学习会话', exact: true }).waitFor();
const secondRun = await client.request('POST', '/api/discovery/runs', runBody, 201); assert.equal(secondRun.status, 'completed');
await page.goto(`${uiBase}/#/discovery/${secondRun.id}?reading=${first.id}`);
await page.getByTestId('reading-selection-error').getByText('这条读取记录不属于当前初筛', { exact: true }).waitFor(); assert.equal(await page.getByTestId('reading-body').count(), 0);
await page.goto(`${uiBase}/#/discovery/${run.id}?reading=reading_missing_fixture`);
await page.getByTestId('reading-selection-error').getByText('本次读取记录无法查看', { exact: true }).waitFor(); assert.equal(await page.getByTestId('reading-body').count(), 0);
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${first.id}`), first);
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${second.id}`), second);
assert.equal(JSON.stringify(await client.request('GET', `/api/discovery/runs/${run.id}`)), runSnapshot);
assert.equal((await client.request('GET', `/api/learning/candidates/${detail.id}`)).digest, detail.digest);
await stop(backend); assert.equal(backend.exitCode, 0); backend = launch(pythonPath(), backendArgs, 'server-restart'); await ready(apiBase, backend);
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${first.id}`), first);
const readings = await client.request('GET', `/api/discovery/runs/${run.id}/readings`); assert.equal(readings.items.length, 4);
await page.goto(`${uiBase}/${firstHash}`); await page.getByTestId('reading-body').getByText(notes[0].body, { exact: true }).waitFor();
await page.screenshot({ path: path.join(output, 'reading-restored.png'), fullPage: true });
assert.equal(receivedWrites, 0); assert.deepEqual(errors, []);
const summary = { result: 'passed', discovery_run_id: run.id, detail_candidate_id: detail.id, completed_reading_id: first.id, partial_reading_id: second.id,
no_candidate_guidance: true, bound_source_before_click: true, two_note_objects: true, partial_body_retained: true, first_level_only: true,
bounded_comment_metadata: true, no_arbitrary_identity_input: true, same_title_wrong_source_blocked: true, wrong_source_dom_clicks: 0,
wrong_source_detail_requests: visits.get(wrongId) || 0, history_immutable: true, hash_reload: true, hash_back_forward: true,
wrong_run_hidden: true, unknown_reading_hidden: true, restart_persistence: true, missing_session_no_auto_login: true,
persisted_readings: readings.items.length, model_connected: false, 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('Reading 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));
}