259 lines
24 KiB
JavaScript
259 lines
24 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';
|
||
|
||
// Own processes, random ports/schema/profiles and synthetic notes only.
|
||
const output = path.join(root, '.tmp', `reading-seek-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 visits = new Map();
|
||
let layout = 'record';
|
||
let receivedWrites = 0;
|
||
const fixture = createServer((request, response) => {
|
||
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: match[1], 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><p class="comment-body">首层合成评论${index},只反映原文。</p>${index === 1 ? '<div class="reply" id="reply-excluded">已展示回复,未作为首层读取</div>' : ''}</article>`).join('')}</section>`;
|
||
} else {
|
||
const filler = count => Array.from({ length: count }, (_, index) => ({ id: (index + 16).toString(16).padStart(24, '0'), title: `合成普通内容 ${index + 1}`, author: '合成填充作者' }));
|
||
const rows = layout === 'record' ? notes : layout === 'duplicate' ? [notes[0], { ...notes[0], id: 'c'.repeat(24) }, notes[1]] : [...filler(layout === 'budget' ? 24 : 6), ...(layout === 'missing' ? [] : [notes[0]]), notes[1]];
|
||
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">${rows.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}" onclick="document.getElementById('click-count').textContent=String(Number(document.getElementById('click-count').textContent)+1)">${escape(note.title)}</a><span class="author">${escape(note.author)}</span></article>`).join('')}</section><p>本次点击计数:<span id="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}.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_seek_' + 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 seek 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 seek 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 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;
|
||
}
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
async function remainingDetailActions(act) {
|
||
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 },
|
||
] });
|
||
await act({ 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 searchTask = await task('有界夹具搜索', 'seek-search-fixture', { keyword: '装修' });
|
||
const searchAct = await searchActions(searchTask.id);
|
||
await searchAct({ 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);
|
||
const oldTask = await task('有界夹具旧七步', 'seek-legacy-fixture', { keyword: '装修', note_title: notes[0].title });
|
||
await remainingDetailActions(await searchActions(oldTask.id));
|
||
const legacy = await freeze(oldTask.id, '保留原七步候选'); assert.equal(legacy.manifest.steps.length, 7);
|
||
const seekTask = await task('有界夹具新八步', 'seek-detail-fixture', { keyword: '装修', note_title: notes[0].title });
|
||
const seekAct = await searchActions(seekTask.id);
|
||
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/${seekTask.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) {
|
||
// Antd's readonly input can move inside its scrolling parent; use the visible Select shell.
|
||
const shell = control.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " ant-select ")][1]');
|
||
await shell.scrollIntoViewIfNeeded(); await shell.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="search-results"]');
|
||
await form.getByLabel('每次像素', { exact: true }).fill('140'); await form.getByLabel('最多次数', { exact: true }).fill('6');
|
||
await form.getByLabel('滚动停止条件(容器内 CSS)', { exact: true }).fill('.legacy-stop');
|
||
await form.getByRole('checkbox', { name: '按动态目标停止滚动', exact: true }).check();
|
||
assert.equal(await form.getByLabel('滚动停止条件(容器内 CSS)', { exact: true }).count(), 0);
|
||
await form.getByLabel('滚动根内目标集合', { exact: true }).fill('[data-testid="note-link"]');
|
||
await select(form.getByLabel('绑定滚动目标参数', { exact: true }), `note_title = ${notes[0].title}`);
|
||
await form.getByLabel('动态查找最长毫秒', { exact: true }).fill('15000');
|
||
const submitted = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/tasks/${seekTask.id}/browser/action`));
|
||
await form.getByRole('button', { name: '执行并记录步骤', exact: true }).click();
|
||
const actionResponse = await submitted; assert.equal(actionResponse.status(), 200, await actionResponse.text());
|
||
const recorded = await actionResponse.json();
|
||
assert.deepEqual(recorded.step.until_target, { selector: '[data-testid="note-link"]', field: 'text', parameter: 'note_title' });
|
||
assert.equal(recorded.step.timeout_ms, 15000); assert.equal(recorded.result.scrolls, 0); assert.equal(recorded.result.target_reached, true);
|
||
for (const key of ['until_selector', 'target', 'min_count', 'complete_only']) assert.ok(!Object.hasOwn(recorded.step, key), `${key} must not leak into dynamic scroll`);
|
||
await page.screenshot({ path: path.join(output, 'seek-form-zero-scroll.png'), fullPage: true });
|
||
await remainingDetailActions(seekAct);
|
||
const detail = await freeze(seekTask.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();
|
||
const pickerGeometry = [];
|
||
for (let index = 0; index < 5; index += 1) {
|
||
pickerGeometry.push(await picker.evaluate(element => {
|
||
const page = element.closest('.workbench-page');
|
||
return { width: element.getBoundingClientRect().width, page_width: page.clientWidth, page_scroll_width: page.scrollWidth };
|
||
}));
|
||
await delay(100);
|
||
}
|
||
assert.ok(pickerGeometry.every(sample => sample.width > 0 && sample.page_scroll_width <= sample.page_width + 1), 'Candidate picker must stay within its page');
|
||
assert.ok(Math.max(...pickerGeometry.map(sample => sample.width)) - Math.min(...pickerGeometry.map(sample => sample.width)) <= 1, 'Candidate picker width must not grow between frames');
|
||
fs.writeFileSync(path.join(output, 'candidate-picker-geometry.json'), JSON.stringify(pickerGeometry, null, 2));
|
||
await select(picker, '保留原七步候选 · v1'); await page.getByTestId('detail-candidate-scope').getByText('七步链路:不包含动态目标滚动', { exact: true }).waitFor();
|
||
await select(picker, '新八步有界查找候选 · v1'); await page.getByTestId('detail-candidate-scope').getByText('八步链路:有界滚动查找目标', { exact: true }).waitFor();
|
||
async function readViaUI(noteId) {
|
||
const received = page.waitForResponse(response => response.request().method() === 'POST' && response.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 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;
|
||
}
|
||
function seekSummary(reading) {
|
||
assert.equal(reading.seek_results.length, 1); const seek = reading.seek_results[0];
|
||
assert.deepEqual(Object.keys(seek).sort(), ['movements', 'scrolls', 'step_position', 'stop_reason', 'target_reached']);
|
||
assert.equal(seek.step_position, 4); assert.equal(seek.movements.length, seek.scrolls); assert.ok(seek.scrolls <= 6);
|
||
for (const move of seek.movements) { assert.deepEqual(Object.keys(move).sort(), ['after', 'before', 'client_height', 'scroll_height']); assert.ok(Object.values(move).every(value => Number.isFinite(value) && value >= 0)); }
|
||
return seek;
|
||
}
|
||
layout = 'reordered';
|
||
const first = await readViaUI(notes[0].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.note_id, notes[0].id);
|
||
assert.equal(first.note_binding_result.href_checked, true); assert.equal(first.note_binding_result.url_checked, true); assert.equal(first.model_connected, false);
|
||
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'));
|
||
const reached = seekSummary(first); assert.equal(reached.target_reached, true); assert.equal(reached.stop_reason, 'target_reached'); assert.ok(reached.scrolls > 0); assert.ok(reached.movements.some(move => move.after > move.before));
|
||
await page.getByTestId('reading-seek-results').getByText(`${reached.scrolls} 次滚动`, { exact: true }).waitFor();
|
||
await page.getByTestId('reading-body').getByText(notes[0].body, { exact: true }).waitFor();
|
||
await page.screenshot({ path: path.join(output, 'reordered-reading-completed.png'), fullPage: true });
|
||
const failures = [];
|
||
for (const mode of ['budget', 'missing', 'duplicate']) {
|
||
layout = mode; const before = visits.get(notes[0].id) || 0;
|
||
const failed = await readViaUI(notes[0].id); assert.equal(failed.status, 'failed', mode); assert.equal(failed.detail, null); assert.equal(failed.comments, null);
|
||
const seek = seekSummary(failed); assert.equal(seek.target_reached, false); assert.ok(failed.error_codes.length);
|
||
if (mode === 'budget') { assert.equal(seek.stop_reason, 'scroll_budget'); assert.equal(seek.scrolls, 6); }
|
||
if (mode === 'missing') assert.ok(['boundary', 'scroll_budget', 'time_budget'].includes(seek.stop_reason));
|
||
if (mode === 'duplicate') { assert.equal(seek.stop_reason, 'ambiguous'); assert.equal(seek.scrolls, 0); }
|
||
assert.equal(visits.get(notes[0].id) || 0, before, `${mode}: no detail request`);
|
||
const state = await client.request('GET', `/api/learning/tasks/${seekTask.id}`);
|
||
assert.match(state.observations.at(-1).text, /本次点击计数[::]\s*0/, `${mode}: actual DOM click counter stays zero`);
|
||
assert.equal(await page.getByTestId('reading-body').count(), 0);
|
||
await page.getByTestId('reading-seek-results').getByText('目标未确认进入视口', { exact: true }).waitFor();
|
||
await page.screenshot({ path: path.join(output, `${mode}-stopped.png`), fullPage: true }); failures.push(failed);
|
||
}
|
||
assert.equal(visits.get('c'.repeat(24)) || 0, 0);
|
||
const firstHash = `#/discovery/${run.id}?reading=${first.id}`;
|
||
await page.goto(`${uiBase}/${firstHash}`); await page.getByTestId('reading-body').getByText(notes[0].body, { exact: true }).waitFor();
|
||
await page.reload(); await page.getByTestId('reading-seek-results').getByText(`${reached.scrolls} 次滚动`, { exact: true }).waitFor();
|
||
await page.getByTestId('discovery-reading-history').locator(`tr[data-row-key="${failures[0].id}"]`).getByRole('button', { name: '查看读取', exact: true }).click();
|
||
await page.getByTestId('discovery-reading-detail').getByText(failures[0].id, { exact: true }).waitFor();
|
||
await page.goBack(); await page.waitForURL(`${uiBase}/${firstHash}`); await page.getByTestId('reading-body').getByText(notes[0].body, { exact: true }).waitFor();
|
||
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${first.id}`), first);
|
||
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);
|
||
assert.equal((await client.request('GET', `/api/learning/candidates/${legacy.id}`)).digest, legacy.digest);
|
||
const validatedCandidate = await client.request('GET', `/api/learning/candidates/${detail.id}`);
|
||
const validation = validatedCandidate.validations.find(item => item.id === first.validation_id); assert.ok(validation);
|
||
assert.deepEqual(validation.seek_results, first.seek_results); assert.equal(validation.environment.verify_budget_ms, 105000);
|
||
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);
|
||
for (const failed of failures) assert.deepEqual(await client.request('GET', `/api/discovery/readings/${failed.id}`), failed);
|
||
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.getByTestId('reading-seek-results').getByText(`${reached.scrolls} 次滚动`, { exact: true }).waitFor();
|
||
await page.screenshot({ path: path.join(output, 'seek-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, legacy_candidate_id: legacy.id,
|
||
completed_reading_id: first.id, failed_reading_ids: failures.map(item => item.id), dynamic_scroll_form: true,
|
||
mutually_exclusive_stop_conditions: true, no_action_field_leak: true, zero_scroll_visible_target: true,
|
||
reordered_target_reached: true, actual_scrolls: reached.scrolls, bound_source_after_seek: true,
|
||
legacy_seven_still_selectable: true, no_implicit_legacy_upgrade: true, candidate_picker_geometry_stable: true, no_page_horizontal_overflow: true, scroll_budget_stop: true,
|
||
missing_target_stop: true, ambiguous_target_stop: true, failed_dom_clicks: 0, failed_detail_requests: 0,
|
||
first_level_only: true, bounded_comment_metadata: true, seek_summary_in_validation: true,
|
||
bounded_verify_budget_ms: validation.environment.verify_budget_ms, history_immutable: true, hash_reload: true,
|
||
hash_history_reopen: true, restart_persistence: true, persisted_readings: readings.items.length,
|
||
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 seek 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));
|
||
}
|