assistant/scripts/test-reading-media.mjs

224 lines
28 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';
// Own processes, random schema/ports/profiles and synthetic media only.
const output = path.join(root, '.tmp', `reading-media-acceptance-${Date.now()}`);
fs.mkdirSync(output, { recursive: true });
const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=', 'base64');
const notes = [
{ id: 'a'.repeat(24), title: '装修求助:新房准备装修,怎么选择施工方案?', author: '合成作者甲', body: '合成正文甲:准备装修,需要核对施工方案。' },
{ id: 'b'.repeat(24), title: '装修公司案例:现代风格完工实拍', author: '合成工作室', body: '合成正文乙:只作案例来源,不能认定真实需求。' },
];
let mode = 'text';
let receivedWrites = 0;
let mediaRequests = 0;
const fixture = createServer((request, response) => {
if (request.method !== 'GET') receivedWrites += 1;
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/media.png') { mediaRequests += 1; response.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }); response.end(png); return; }
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) {
const note = notes.find(item => item.id === match[1]);
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:320px;overflow:auto;border:1px solid;padding:8px;box-sizing:border-box">${[1, 2, 3].map(index => {
const broken = mode === 'broken' && index === 1;
const text = broken || (mode !== 'text' && index === 2) ? '' : `<span class="comment-text">${index === 3 ? '混合文字原文:看图分享施工想法。' : `合成评论文字${index},只反映原文。`}</span>`;
const images = !broken && mode !== 'text' && index >= 2 ? Array.from({ length: index === 3 ? 2 : 1 }, () => '<img class="comment-image" src="/media.png" width="40" height="40">').join('') : '';
return `<article class="top-level-comment" id="comment-${note.id[0]}-${index}" style="height:150px;box-sizing:border-box;padding:8px;margin:0"><span class="comment-author">合成评论作者${index}</span><div class="comment-content" style="min-height:70px">${text}${images}</div>${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" style="height:240px;overflow:auto;border:1px solid;box-sizing:border-box">${notes.map(note => `<article class="note-card" style="height:100px;box-sizing:border-box;padding:8px;margin:0"><a data-testid="note-link" class="title" href="/explore/${note.id}">${escape(note.title)}</a><span class="author">${escape(note.author)}</span></article>`).join('')}</section>`;
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}.author,.comment-text{display:block}.comment-image{margin:4px;vertical-align:top}.reply{font-size:11px;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_media_' + 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 media fixture service exited');
try { const response = await fetch(base + '/api/health', { signal: AbortSignal.timeout(1500) }); if (response.ok) { const health = await response.json(); if (health.instance_id === environment.ASSISTANT_INSTANCE_ID) return health; } } catch {}
await delay(250);
}
throw Error('Owned media 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'); const health = 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 open(created.id); return created;
}
async function open(taskId) {
await client.request('POST', `/api/learning/tasks/${taskId}/browser/open`, {});
await client.request('POST', `/api/learning/tasks/${taskId}/browser/ready`, { identity_label: '媒体合成维护者' });
await client.request('POST', `/api/learning/tasks/${taskId}/browser/capture`, {});
}
const close = taskId => client.request('POST', `/api/learning/tasks/${taskId}/browser/close`, {});
async function freeze(taskId, name) {
const candidate = await client.request('POST', `/api/learning/tasks/${taskId}/candidates`, { name }, 201);
const checked = await client.request('POST', `/api/learning/candidates/${candidate.id}/check`, {});
assert.equal(checked.status, 'passed', JSON.stringify(checked.errors)); return candidate;
}
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 bodyField = { name: 'body', selector: '.comment-content', source: 'content', text_selector: '.comment-text', media_selector: 'img.comment-image', max_media: 1, required: true };
const commentsAction = content => ({ 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: 150, 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 },
content ? bodyField : { name: 'body', selector: '.comment-text', source: 'text', required: true },
] });
async function detailPrefix(taskId, seek) {
const act = await searchActions(taskId);
if (seek) await act({ kind: 'scroll', selector: '[data-testid="search-results"]', direction: 'down', pixels: 140, max_scrolls: 6, until_target: { selector: '[data-testid="note-link"]', field: 'text', parameter: 'note_title' }, timeout_ms: 15000 });
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 },
] }); return act;
}
const searchTask = await task('媒体夹具搜索', 'media-search-fixture', { keyword: '装修' });
await (await searchActions(searchTask.id))({ 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 search = await freeze(searchTask.id, '媒体夹具搜索候选');
const run = await client.request('POST', '/api/discovery/runs', { search_candidate_id: search.id, keyword: '装修', service: '装修服务', target_customer: '有装修需求的业主', region: '', max_selected: 2 }, 201);
assert.equal(run.status, 'completed', JSON.stringify(run.errors)); assert.equal(run.selected.length, 2); const runSnapshot = JSON.stringify(run); await close(searchTask.id);
const legacyTask = await task('媒体夹具旧文字七步', 'media-legacy-fixture', { keyword: '装修', note_title: notes[0].title });
await (await detailPrefix(legacyTask.id, false))(commentsAction(false)); const legacy = await freeze(legacyTask.id, '原文字必填七步'); await close(legacyTask.id);
mode = 'typed';
const typedTask = await task('媒体夹具内容七步', 'media-seven-fixture', { keyword: '装修', note_title: notes[0].title });
await (await detailPrefix(typedTask.id, false))(commentsAction(true)); const typed = await freeze(typedTask.id, '媒体内容七步'); await close(typedTask.id);
const detailTask = await task('媒体夹具内容八步', 'media-eight-fixture', { keyword: '装修', note_title: notes[0].title });
await detailPrefix(detailTask.id, true);
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}/#/tasks/${detailTask.id}`); await page.getByTestId('auth-username').fill('admin'); await page.getByTestId('auth-password').fill('123456'); await page.getByTestId('auth-submit').click();
const form = page.getByTestId('learning-action-form'); await form.waitFor();
async function select(control, label) { await control.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " ant-select ")][1]').click(); await page.locator('.ant-select-dropdown:visible').getByText(label, { exact: true }).click(); }
await select(form.getByRole('combobox', { name: '操作类型', exact: true }), '结构化提取');
await form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('[data-testid="comments-root"]');
await form.getByLabel('容器内结果行(CSS,可选)', { exact: true }).fill('.top-level-comment'); await form.getByLabel('输出名称', { exact: true }).fill('comments'); await form.getByLabel('最多提取项数', { exact: true }).fill('5');
const contentField = form.locator('.extraction-field').first();
await contentField.getByLabel('字段名称', { exact: true }).fill('body'); await contentField.getByLabel('行内定位', { exact: true }).fill('.comment-content');
await contentField.getByRole('checkbox', { name: '每项必须包含该字段', exact: true }).uncheck();
await select(contentField.getByLabel('取值来源', { exact: true }), '只读属性'); await contentField.getByLabel('属性名称', { exact: true }).fill('data-stale');
await select(contentField.getByLabel('取值来源', { exact: true }), '文字与媒体内容');
const required = contentField.getByRole('checkbox', { name: '每项必须包含该字段', exact: true }); assert.equal(await required.isChecked(), true); assert.equal(await required.isDisabled(), true);
await contentField.getByLabel('内容根内文字定位', { exact: true }).fill('.comment-text'); await contentField.getByLabel('内容根内媒体定位', { exact: true }).fill('img.comment-image'); await contentField.getByLabel('每项最多记录媒体数', { exact: true }).fill('1');
await form.getByRole('button', { name: '增加提取字段', exact: true }).click(); const idField = form.locator('.extraction-field').nth(1);
await idField.getByLabel('字段名称', { exact: true }).fill('comment_id'); await select(idField.getByLabel('取值来源', { exact: true }), '只读属性'); await idField.getByLabel('属性名称', { exact: true }).fill('id');
await form.getByRole('button', { name: '增加提取字段', exact: true }).click(); const authorField = form.locator('.extraction-field').nth(2);
await authorField.getByLabel('字段名称', { exact: true }).fill('author'); await authorField.getByLabel('行内定位', { exact: true }).fill('.comment-author');
await form.getByRole('checkbox', { name: '有限多批读取并去重', exact: true }).check();
await form.getByLabel('批次滚动容器定位', { exact: true }).fill('[data-testid="comments-root"]'); await form.getByLabel('每批滚动像素', { exact: true }).fill('150'); await form.getByLabel('最多读取批数', { exact: true }).fill('2'); await select(form.getByLabel('跨批去重字段', { exact: true }), 'comment_id (id)');
const submitted = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/tasks/${detailTask.id}/browser/action`)); await form.getByRole('button', { name: '执行并记录步骤', exact: true }).click();
const response = await submitted; assert.equal(response.status(), 200, await response.text()); const recorded = await response.json();
assert.deepEqual(recorded.step.fields.find(field => field.name === 'body'), bodyField); assert.ok(!Object.hasOwn(recorded.step.fields.find(field => field.name === 'body'), 'attribute'));
for (const field of recorded.step.fields.filter(field => field.source !== 'content')) for (const key of ['text_selector', 'media_selector', 'max_media']) assert.ok(!Object.hasOwn(field, key));
assert.deepEqual(recorded.result.items.map(item => item.body.kind), ['text', 'media', 'mixed']);
await page.screenshot({ path: path.join(output, 'media-content-form.png'), fullPage: true }); const detail = await freeze(detailTask.id, '媒体内容八步'); assert.equal(detail.manifest.steps.length, 8);
await page.goto(`${uiBase}/#/discovery/${run.id}`); const picker = page.getByRole('combobox', { name: '详情读取候选', exact: true }); await picker.waitFor();
async function choose(name) { await select(picker, name + ' · v1'); }
async function read(noteId, candidateId) {
const received = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith(`/api/discovery/runs/${run.id}/readings`), { timeout: 120000 });
await page.locator(`[data-testid="discovery-selected-note"][data-note-id="${noteId}"]`).getByRole('button', { name: '读取正文与评论', exact: true }).click();
const reply = await received; assert.equal(reply.status(), 201, await reply.text()); assert.deepEqual(reply.request().postDataJSON(), { detail_candidate_id: candidateId, note_id: noteId }); const reading = await reply.json();
await page.getByTestId('discovery-reading-detail').getByText(reading.id, { exact: true }).waitFor(); return reading;
}
const phases = new Set(['validate', 'inspect_current', 'capture_before', 'resolve_target', 'execute', 'settle', 'request_guard', 'capture_after', 'failure_capture', 'batch_capture']);
function timing(reading, steps) {
assert.ok(reading.step_timings.length > 0 && reading.step_timings.length <= steps); assert.deepEqual(Object.keys(reading.verify_timing).sort(), ['close_ms', 'duration_ms', 'entry_ms']);
assert.ok(Number.isFinite(reading.verify_timing.duration_ms) && reading.verify_timing.duration_ms >= 0); assert.ok(Number.isFinite(reading.verify_timing.entry_ms) && reading.verify_timing.entry_ms >= 0);
assert.ok(reading.verify_timing.close_ms === null || (Number.isFinite(reading.verify_timing.close_ms) && reading.verify_timing.close_ms >= 0));
for (const step of reading.step_timings) { assert.deepEqual(Object.keys(step).sort(), ['duration_ms', 'kind', 'phase_ms', 'status', 'step_position']); assert.ok(step.step_position >= 1 && step.step_position <= steps); assert.ok(Number.isFinite(step.duration_ms) && step.duration_ms >= 0); assert.ok(['passed', 'failed', 'interrupted'].includes(step.status)); for (const [phase, value] of Object.entries(step.phase_ms)) { assert.ok(phases.has(phase)); assert.ok(Number.isFinite(value) && value >= 0); } }
}
await choose('媒体内容八步'); await page.getByTestId('detail-comment-contract').getByText(/明确的文字与媒体内容契约/).waitFor();
const first = await read(notes[0].id, detail.id); assert.equal(first.status, 'completed', JSON.stringify({ errors: first.errors, codes: first.error_codes }));
assert.equal(first.detail.items[0].body, notes[0].body); 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.deepEqual(first.comments.items.map(item => item.body.kind), ['text', 'media', 'mixed']); assert.equal(first.comments.items[1].body.text, null); assert.equal(first.comments.items[2].body.text, '混合文字原文:看图分享施工想法。'); assert.equal(first.comments.items[2].body.media_truncated, true); assert.equal(first.comments.items[2].body.media.length, 1);
assert.equal(first.comments.count, 3); assert.equal(first.comments.batch_summary.batches_read, 2); assert.ok(!JSON.stringify(first.comments.items).includes('reply-excluded')); assert.ok(!/"(?:src|alt|ocr)"/.test(JSON.stringify(first.comments)));
timing(first, 8); assert.equal(first.step_timings.length, 8); assert.ok(first.step_timings.every(step => step.status === 'passed')); assert.ok(first.step_timings.some(step => Object.hasOwn(step.phase_ms, 'batch_capture')));
assert.equal(await page.getByTestId('typed-comment-content').count(), 3); await page.getByTestId('reading-comments').getByText('媒体内容', { exact: true }).waitFor(); await page.getByTestId('reading-comments').getByText('文字与媒体混合', { exact: true }).waitFor(); await page.getByTestId('reading-comments').getByText('媒体记录范围已截断', { exact: true }).waitFor();
const timingUI = page.getByTestId('reading-execution-timings'); assert.equal(await timingUI.getByTestId('execution-step-timing').count(), 8); await timingUI.getByTestId('execution-step-timing').last().locator('summary').click(); await timingUI.getByText('批次取证', { exact: true }).waitFor();
await page.getByTestId('discovery-reading-detail').getByRole('button', { name: /^查看阅读证据/ }).click(); const drawer = page.getByRole('dialog', { name: '本次阅读证据', exact: true }); await drawer.waitFor(); assert.ok(await drawer.locator('img').count());
await page.waitForFunction(() => [...document.querySelectorAll('[role="dialog"] img')].some(image => image.complete && image.naturalWidth > 0));
await page.screenshot({ path: path.join(output, 'media-real-batch-evidence.png'), fullPage: true, animations: 'disabled' }); await page.getByRole('button', { name: /关闭|close/i }).last().click();
await page.screenshot({ path: path.join(output, 'typed-reading-timings.png'), fullPage: true }); const firstHash = `#/discovery/${run.id}?reading=${first.id}`;
await page.goto(`${uiBase}/#/tasks/${detailTask.id}`); await page.getByRole('tab', { name: /^验证记录 / }).click(); await page.locator(`tr[data-row-key="${first.validation_id}"]`).getByRole('button', { name: /^查\s*看$/ }).click(); await page.getByTestId('validation-execution-timings').getByTestId('execution-step-timing').last().locator('summary').click(); await page.getByTestId('validation-execution-timings').getByText('批次取证', { exact: true }).waitFor();
await page.goto(`${uiBase}/${firstHash}`); await page.getByTestId('reading-comments').getByText('媒体内容', { exact: true }).waitFor(); await open(typedTask.id); await choose('媒体内容七步');
const seven = await read(notes[1].id, typed.id); assert.equal(seven.status, 'completed', JSON.stringify(seven.errors)); assert.equal(seven.note_binding_result.note_id, notes[1].id); assert.equal(seven.detail.items[0].body, notes[1].body); timing(seven, 7); assert.equal(seven.step_timings.length, 7); assert.ok(!Object.hasOwn(seven, 'seek_results')); await close(typedTask.id);
await open(legacyTask.id); await choose('原文字必填七步'); await page.getByTestId('detail-comment-contract').getByText(/缺失必填文字仍会停止/).waitFor(); const old = await read(notes[0].id, legacy.id);
assert.equal(old.status, 'partial'); assert.equal(old.detail.items[0].body, notes[0].body); assert.equal(old.comments, null); assert.ok(old.error_codes.length); timing(old, 7); assert.equal(old.step_timings.at(-1).status, 'failed'); await close(legacyTask.id);
mode = 'broken'; await choose('媒体内容八步'); const broken = await read(notes[1].id, detail.id); assert.equal(broken.status, 'partial'); assert.equal(broken.detail.items[0].body, notes[1].body); assert.equal(broken.comments, null); assert.ok(broken.error_codes.length); timing(broken, 8); assert.equal(broken.step_timings.at(-1).status, 'failed'); assert.equal(await page.getByTestId('typed-comment-content').count(), 0);
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${first.id}`), first); assert.deepEqual(await client.request('GET', `/api/discovery/readings/${seven.id}`), seven); assert.equal(JSON.stringify(await client.request('GET', `/api/discovery/runs/${run.id}`)), runSnapshot);
const candidate = await client.request('GET', `/api/learning/candidates/${detail.id}`); assert.equal(candidate.digest, detail.digest); const verification = candidate.validations.find(record => record.id === first.validation_id); assert.deepEqual(verification.step_timings, first.step_timings); assert.deepEqual(verification.verify_timing, first.verify_timing);
assert.equal((await client.request('GET', `/api/learning/candidates/${legacy.id}`)).digest, legacy.digest); assert.equal(legacy.manifest.steps.at(-1).fields.find(field => field.name === 'body').source, 'text');
await page.goto(`${uiBase}/${firstHash}`); await page.reload(); await page.getByTestId('reading-comments').getByText('媒体内容', { exact: true }).waitFor(); await page.getByTestId('reading-execution-timings').getByTestId('execution-step-timing').first().waitFor();
await stop(backend); assert.equal(backend.exitCode, 0); backend = launch(pythonPath(), backendArgs, 'server-restart'); await ready(apiBase, backend);
for (const reading of [first, seven, old, broken]) assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}`), reading); 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-comments').getByText('媒体内容', { exact: true }).waitFor(); await page.screenshot({ path: path.join(output, 'media-reading-restored.png'), fullPage: true });
assert.ok(mediaRequests > 0); assert.equal(receivedWrites, 0); assert.deepEqual(errors, []);
const summary = { result: 'passed', discovery_run_id: run.id, detail_candidate_id: detail.id, seven_candidate_id: typed.id, legacy_candidate_id: legacy.id,
completed_reading_ids: [first.id, seven.id], partial_reading_ids: [old.id, broken.id], explicit_content_form: true, content_required_locked: true, no_source_field_leak: true,
text_media_mixed: true, media_truncation_preserved: true, media_uninterpreted_no_ocr: true, actual_batch_screenshot_evidence: true, old_required_text_still_fails: true,
empty_content_still_fails: true, seven_and_eight_content_contracts: true, source_binding_checked: true, first_level_only: true, bounded_comment_metadata: true,
step_phase_timing_in_reading_and_validation: true, timing_ui: true, partial_failed_timing: true, history_immutable: true, hash_reload: true, restart_persistence: true,
persisted_readings: readings.items.length, media_requests: mediaRequests, database_backend: health.database_backend, 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 media 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));
}