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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 = `

${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' }); 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)); }