assistant/scripts/test-reading-seek.mjs

259 lines
24 KiB
JavaScript
Raw Permalink 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 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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));
}