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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 = `${escape(note.title)}
${escape(note.body)}
${escape(note.author)}
${[1, 2, 3].map(index => {
const broken = mode === 'broken' && index === 1;
const text = broken || (mode !== 'text' && index === 2) ? '' : ``;
const images = !broken && mode !== 'text' && index >= 2 ? Array.from({ length: index === 3 ? 2 : 1 }, () => '').join('') : '';
return ``;
}).join('')}`;
} else content = `
隔离媒体评论搜索
`;
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_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));
}