Files
mini-openclaw-v2/scripts/browser-e2e-modules.cjs
qiaoqichaoandClaude Code c880f40e4c fix(v2): 修掉预审面板不刷新与候选行同 id 连开多行的缺陷
真实 Chrome 端到端验证第二篇(/prescreen-workbench、/experience-center)发现两个
源码守卫与生产构建都看不见的缺陷:

- 发起 AI 预审、运行结束后 PrescreenPanel 不重载:新运行进不了"查看哪一次运行",
  面板继续显示上一次运行,而徽标写着"结果会自动刷新"。根因是 load() 只依赖
  project.id;补修时又发现 worker 先写 run.status、再另写 prescreen_run 视图
  (assistant_worker.process_next),两者不在同一事务,只读一次会读到视图还没
  落库的中间态,因此改为按运行集合重载并在最新运行进入视图前短时重试。
- 候选区 key={card.id} 撞 React key,且「自然语言修改适用范围」「补充独立测试」
  按 card.id 开合,点一行连开 4 行(同一 card.id 的草稿行与各版本行共享 id)。
  改为行级 rowKey(组件id@版本@状态)。

同时新增可选脚本 scripts/browser-e2e-modules.cjs(42/42 通过),并给
test_prescreen_ui.cjs / test_experience_center_ui.cjs 补上对应源码断言。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 09:35:29 +08:00

440 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.
/**
* 真实浏览器端到端验证(第二篇):AI 预审 / 历史经验加工
*
* 第四批用 `browser-e2e.cjs` 走过 `/trajectory-center`,并把 F11 记为「部分关闭」:
* `/experience-center` 与 `/prescreen-workbench` 当时只验到「能渲染」,交互没有逐项点过。
* 本脚本补的就是这两个表面——它驱动的都是**真实点击**(发起预审、重建时间线、请求提炼、
* 展开依据抽屉、切换身份),断言取真实 DOM 与真实执行链的结果,不做接口 mock。
*
* **刻意不接入 `scripts/verify.py`**:verify 是本机随时可跑的强制门禁,不应依赖浏览器
* 是否存在。是否把它写进门禁是产品/工程决策(见 15 交接第 7 节 F27)。
*
* 依赖与用法:
* 1. 本机有 Chrome(或用 CHROME_BIN 指定路径)
* 2. npm i --no-save puppeteer-core # 不写入 package.json
* 3. 起后端:cd backend && XINGBANG_DEMO_ROLE_LOGIN=1 \
* venv/Scripts/python.exe -m uvicorn app:app --host 127.0.0.1 --port 8012
* 4. 起前端:RISK_AGENT_BACKEND_URL=http://127.0.0.1:8012 npm run dev -- -p 3212
* 5. node scripts/browser-e2e-modules.cjs # 截图与 result.json 落在 E2E_OUT
*
* 注意:脚本**真的写演示库**,不是只读冒烟——每次运行会
* ① 新建一次预审运行(`/prescreen-workbench` 的「开始 AI 预审」);
* ② 重建一次 DEMO-B 的历史时间线(同 id 递增 revision,不新建对象);
* ③ 留下一条 `status=failed` 的提炼记录(未配置模型时的真实结果,E-28)。
* 与 F28 记录的现象同类:重复运行会累积。提炼那一条是本脚本要证明的「失败就是失败」,
* 不是脏数据被隐藏。
*/
const fs = require("node:fs");
const path = require("node:path");
const puppeteer = require("puppeteer-core");
const CHROME = process.env.CHROME_BIN || "C:/Program Files/Google/Chrome/Application/chrome.exe";
const APP = process.env.E2E_APP || "http://localhost:3212";
const OUT = process.env.E2E_OUT || path.join(__dirname, "..", "test-evidence", "browser-e2e-modules");
const ROLE = process.env.E2E_ROLE || "manager";
const PROJECT = process.env.E2E_PROJECT || "DEMO-B";
const results = [];
function check(label, ok, detail = "") {
results.push({label, ok, detail: String(detail)});
console.log(`${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
}
const bodyText = (page) => page.evaluate(() => document.body.innerText);
const noisy = (list) => list.filter((text) => !/favicon|404 \(Not Found\)/i.test(text));
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const shot = (page, name, fullPage = false) => page.screenshot({path: path.join(OUT, name), fullPage});
/** 点击第一个满足条件的按钮;找不到或禁用则返回 false。 */
const clickButton = (page, match) => page.evaluate((needle) => {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes(needle));
if (!button || button.disabled) return false;
button.click();
return true;
}, match);
// ------------------------------------------------------------------ AI 预审
async function sectionPrescreen(page, consoleErrors) {
console.log("\n=== AI 预审(/prescreen-workbench)===");
await page.goto(`${APP}/prescreen-workbench?project=${PROJECT}`, {waitUntil: "networkidle2"});
await page.waitForFunction(
() => document.body.innerText.includes("预审与推进沙盘")
|| document.body.innerText.includes("没有可访问的项目"),
{timeout: 30000});
const heading = await page.evaluate(() => {
const node = document.querySelector("main h1");
return node ? node.textContent.trim() : null;
});
check("AI 预审标题渲染", heading === "AI 预审工作台", `h1=${heading}`);
// 这一条正是第四批的缺陷 1:读错 `/projects` 字段时项目列表恒为空、整页只剩空态,
// 而空态看着完全合理。所以断言必须落在"下拉里真的有项目"上。
const projects = await page.evaluate(() => {
const select = document.querySelector('select[aria-label="选择项目"]');
return select ? [...select.options].map((item) => item.value) : null;
});
check("项目下拉有可选项(/projects 字段读取正确)",
Boolean(projects && projects.length), JSON.stringify(projects));
const plans = await page.evaluate(() => {
const select = document.querySelector('select[aria-label="评审方案"]');
return select ? [...select.options].map((item) => item.textContent.trim()) : null;
});
check("评审方案下拉有可选项", Boolean(plans && plans.length), JSON.stringify(plans));
const runOptions = () => page.evaluate(() => {
const select = document.querySelector('select[aria-label="选择预审运行"]');
return select ? [...select.options].map((item) => item.textContent.trim()) : [];
});
const snapshotLine = () => page.evaluate(() => {
const match = document.body.innerText.match(/输入快照 (\S+)/);
return match ? match[1] : "";
});
const runsBefore = await runOptions();
const snapshotBefore = await snapshotLine();
// ---- 真实点击「开始 AI 预审」:这是第一批留下的"界面点击未由自动化驱动"缺口
check("「开始 AI 预审」按钮可点击", await clickButton(page, "开始 AI 预审"));
await page.waitForFunction(() => {
const node = document.querySelector('[role="status"]');
return node && node.textContent.includes("预审已进入队列");
}, {timeout: 20000}).catch(() => {});
const notice = await page.evaluate(() => {
const node = document.querySelector('[role="status"]');
return node ? node.textContent.trim() : "";
});
check("点击后给出入队提示", /预审已进入队列/.test(notice), `notice=${notice}`);
// 顺序很重要:先等页面**看见**这次运行在执行(徽标出现),再等它结束。
// 反过来写会立刻成立——setNotice 先于 open() 完成,那一刻 project.runs 里还没有新运行,
// "徽标不存在"于是为真,后面全部断言都落在上一次运行的面板上(第一次跑就是这样误判的)。
await page.waitForFunction(() => document.body.innerText.includes("预审执行中,结果会自动刷新"),
{timeout: 90000, polling: 300}).catch(() => {});
await page.waitForFunction(() => !document.body.innerText.includes("预审执行中,结果会自动刷新")
&& document.body.innerText.includes("预审与推进沙盘"),
{timeout: 240000, polling: 500}).catch(() => {});
check("新运行执行结束(页面不再显示执行中)",
!(await bodyText(page)).includes("预审执行中,结果会自动刷新"));
// 徽标写着"结果会自动刷新":先不刷新页面,看面板自己有没有跟上新运行。
// 给它一个有上限的宽限:run.status 与预审视图不是同一事务写入的,晚一两拍是正常的;
// 但"永远不刷新"是缺陷——面板原先只在 project.id 变化时 load(),发起的运行根本进不来。
const panelPickedUp = await page.waitForFunction((count) => {
const select = document.querySelector('select[aria-label="选择预审运行"]');
return select && select.options.length > count;
}, {timeout: 12000, polling: 500}, runsBefore.length).then(() => true).catch(() => false);
check("发起预审后结果面板自动刷新(无需手动刷新页面)", panelPickedUp,
`不刷新页面时运行下拉 ${runsBefore.length}→${(await runOptions()).length}`);
// 刷新页面:证明新运行确实在服务端,缺的只是面板的刷新时机
await page.reload({waitUntil: "networkidle2"});
// 面板外壳是静态 JSX,标题一出现就断言会量到"还没取数的空壳"(第一次跑就栽在这里)
await page.waitForFunction((count) => {
const select = document.querySelector('select[aria-label="选择预审运行"]');
return select && select.options.length > count;
}, {timeout: 30000}, runsBefore.length).catch(() => {});
await wait(500);
const afterRun = await bodyText(page);
const runsAfter = await runOptions();
const snapshotAfter = await snapshotLine();
check("刷新页面后能读到本次发起的预审记录",
runsAfter.length > runsBefore.length || (snapshotAfter && snapshotAfter !== snapshotBefore),
`运行下拉 ${runsBefore.length}→${runsAfter.length};输入快照 ${snapshotBefore}→${snapshotAfter}`);
const statusLine = (afterRun.match(/运行 (已完成|部分完成|未完成|已取消|排队中|执行中)/) || [])[1] || "";
check("运行状态为终态", Boolean(statusLine) && !["排队中", "执行中"].includes(statusLine), statusLine);
// ---- 模型不可用时如实标未完成(BR-47)。配了模型时这条降级记录本就不该出现。
const degradation = await page.evaluate(() => {
const node = [...document.querySelectorAll("p")]
.find((p) => p.textContent.includes("未完成项:"));
return node ? node.textContent.trim() : "";
});
if (degradation) {
check("模型不可用时如实显示未完成项与错误码",
/([A-Z0-9_-]+)/.test(degradation), degradation.slice(0, 140));
} else {
check("本次运行无降级记录(已配置模型,非失败路径)", true);
}
// ---- 概率口径:数字要么不出现,要么必须紧邻"演示值/人工设定"
const probability = await page.evaluate(() => {
const node = [...document.querySelectorAll("div.xb-finding")]
.find((d) => d.textContent.includes("审批通过概率"));
return node ? node.innerText : "";
});
const hasPercent = /\d+%/.test(probability);
const labelled = /演示值|人工设定/.test(probability);
// 概率区必须真的渲染出来:空串会让"没有裸数字"变成一句空断言
check("概率数字必带演示值/人工设定说明(AC-12)",
probability.length > 0 && (!hasPercent || labelled),
probability.split("\n").slice(0, 2).join(" / ").slice(0, 140));
check("声明证据阶段不等于审批概率", /证据阶段 ≠ 审批概率/.test(afterRun));
// ---- 依据抽屉:真实打开、真实关闭、焦点回位
const opened = await page.evaluate(() => {
const button = [...document.querySelectorAll("button")]
.find((b) => /^查看 .+ 的依据$/.test(b.getAttribute("aria-label") || ""));
if (!button) return null;
button.dataset.e2eOpener = "1";
button.click();
return button.getAttribute("aria-label");
});
check("存在「查看依据」按钮", Boolean(opened), opened || "未找到");
await page.waitForFunction(() => Boolean(document.querySelector('[role="dialog"]')), {timeout: 10000})
.catch(() => {});
const dialog = await page.evaluate(() => {
const node = document.querySelector('[role="dialog"]');
return node ? {label: node.getAttribute("aria-label"), text: node.innerText} : null;
});
check("依据抽屉打开并给出依据与版本",
Boolean(dialog) && /依据与版本/.test(dialog.text), dialog ? dialog.label : "未打开");
await shot(page, "03-prescreen-basis-drawer.png");
await clickButton(page, "关闭依据");
await wait(300);
check("关闭依据后抽屉消失",
await page.evaluate(() => !document.querySelector('[role="dialog"]')));
check("关闭依据后焦点回到触发按钮",
await page.evaluate(() => document.activeElement && document.activeElement.dataset.e2eOpener === "1"));
// ---- 推进沙盘:有假设动作就点一个真的重算;没有就断言它是"诚实的空态"
const assumptions = await page.evaluate(() => {
const heading = [...document.querySelectorAll("h3")].find((h) => h.textContent.includes("推进沙盘"));
if (!heading) return null;
return [...heading.parentElement.querySelectorAll('input[type="checkbox"]')]
.map((box) => (box.closest("label") ? box.closest("label").innerText.trim().slice(0, 40) : ""));
});
if (assumptions === null) {
check("存在推进沙盘区", false, "未找到推进沙盘标题");
} else if (!assumptions.length) {
// 假设动作只指向本次方案真实存在的维度(服务端 available_assumptions),
// 演示方案的维度里没有 DC-CASH/DC-TITLE,因此这里给出空态而不是必然失败的选项。
check("推进沙盘无可用动作时给出诚实空态",
/当前方案没有可推演的假设动作/.test(afterRun),
"演示方案的维度不含假设动作目标维度");
} else {
const box = await page.evaluate(() => {
const heading = [...document.querySelectorAll("h3")].find((h) => h.textContent.includes("推进沙盘"));
const input = heading.parentElement.querySelector('input[type="checkbox"]');
input.click();
return true;
});
check("推进沙盘假设动作可勾选", box, JSON.stringify(assumptions));
await page.waitForFunction(
() => /所选情景|情景未完成|该情景未完成/.test(document.body.innerText),
{timeout: 20000}).catch(() => {});
const scenario = await bodyText(page);
check("勾选后给出情景结果或如实标未完成",
/所选情景|情景未完成|该情景未完成/.test(scenario));
check("情景不改写真实预审(仍显示基线待核实项)", /真实预审保持不变/.test(scenario));
await clickButton(page, "返回真实预审");
await wait(500);
check("可返回真实预审(恢复基线)",
!/真实预审保持不变/.test(await bodyText(page)));
}
await shot(page, "04-prescreen-workbench.png", true);
}
// ------------------------------------------------------------------ 历史经验加工
async function sectionExperience(page, consoleErrors) {
console.log("\n=== 历史经验加工(/experience-center)===");
consoleErrors.length = 0;
await page.goto(`${APP}/experience-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
await page.waitForFunction(
() => document.body.innerText.includes("① 来源")
|| document.body.innerText.includes("没有可访问的项目"),
{timeout: 30000});
const heading = await page.evaluate(() => {
const node = document.querySelector("main h1");
return node ? node.textContent.trim() : null;
});
check("历史经验加工标题渲染", heading === "历史经验加工中心", `h1=${heading}`);
const loaded = await bodyText(page);
for (const label of ["① 来源", "② 提炼", "③ 候选", "④ 方案"]) {
check(`四段流程含「${label}」`, loaded.includes(label));
}
const projects = await page.evaluate(() => {
const select = document.querySelector('select[aria-label="选择项目"]');
return select ? [...select.options].map((item) => item.value) : null;
});
check("项目下拉有可选项(/projects 字段读取正确)",
Boolean(projects && projects.length), JSON.stringify(projects));
// ---- ① 时间线:真实点击重建
const cardRows = () => page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("自然语言修改适用范围")).length);
check("「从项目材料重建时间线」按钮可点击", await clickButton(page, "从项目材料重建时间线"));
await page.waitForFunction(
() => /已恢复 · \d+ 个事件|过程材料不完整 · \d+ 个事件/.test(document.body.innerText),
{timeout: 30000}).catch(() => {});
const timelineText = await bodyText(page);
const timelineBadge = (timelineText.match(/(已恢复|过程材料不完整) · (\d+) 个事件/) || []);
check("点击后时间线给出状态与事件数", Boolean(timelineBadge[0]), timelineBadge[0] || "未出现徽标");
check("时间线不编造经过(无结构化记录时明确标注不完整)",
!/过程材料不完整/.test(timelineText) || /未发现结构化的问询\/决定记录/.test(timelineText));
// act() 期间 busy=true 会让按钮保持禁用;先等这次重建真正结束,否则量到的是中间态
await page.waitForFunction(() => {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes("请求提炼"));
return button && !button.disabled;
}, {timeout: 15000}).catch(() => {});
const extractEnabled = await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes("请求提炼"));
return button ? !button.disabled : null;
});
check("时间线就绪后「请求提炼」可用", extractEnabled === true);
// ---- ② 真实提炼:模型没配就必须是"未完成",且一张候选都不许产生(BR-48)
const before = await cardRows();
// 先走一次"没有来源事件、也没有提炼要求":服务端按契约拒绝,界面必须如实显示这条理由,
// 而不是安静地什么都不发生或者伪造一条草稿。
await clickButton(page, "请求提炼");
await page.waitForFunction(() => Boolean(document.querySelector('[role="alert"]')), {timeout: 15000})
.catch(() => {});
const rejection = await page.evaluate(() => {
const node = document.querySelector('[role="alert"]');
return node ? node.textContent.trim() : "";
});
check("没有来源事件也没有提炼要求时如实拒绝",
/至少给出一个来源事件或一条提炼要求/.test(rejection), rejection.slice(0, 120));
// 再真的发一次能通过的提炼。演示项目的时间线是 0 个事件(材料里没有结构化的问询/决定记录),
// 所以这里用"提炼要求"满足服务端的前置条件——顺带说明来源事件勾选在本数据集下点不到。
await page.type('input[placeholder*="重点整理"]', "重点整理回款依赖的核查方法");
check("「请求提炼」按钮可点击", await clickButton(page, "请求提炼"));
await page.waitForFunction(
() => /提炼未完成|提炼完成/.test(document.body.innerText), {timeout: 90000}).catch(() => {});
const afterExtract = await bodyText(page);
check("提炼记录被渲染出来", /提炼未完成|提炼完成/.test(afterExtract));
if (/提炼未完成/.test(afterExtract)) {
check("模型不可用时显示 E-28 文案与错误码",
/本次未能完成经验提炼/.test(afterExtract) && /EXTRACTION_UNAVAILABLE/.test(afterExtract));
}
const after = await cardRows();
check("提炼未完成时不产生候选草稿(BR-48)", after === before, `候选行 ${before} → ${after}`);
// ---- ③ 候选卡渲染:来源与状态可区分,且来源不是靠颜色单独传意(AC-42)
const cards = await page.evaluate(() => {
const badges = [...document.querySelectorAll(".xb-origin")]
.map((node) => ({text: node.textContent.trim(), icon: Boolean(node.querySelector("svg"))}));
return {count: badges.length, sample: badges.slice(0, 3)};
});
check("候选卡渲染来源徽标(文字+图标)",
cards.count > 0 && cards.sample.every((b) => b.text && b.icon), JSON.stringify(cards.sample));
check("候选卡渲染状态与版本徽标",
/(草稿|已发布)/.test(afterExtract) && /v\d+\.\d+\.\d+ · revision \d+/.test(afterExtract));
// ---- 修订面板:修的是草稿,且不能一键发布
// act() 是"先显示提示、再刷新、最后解除 busy":提示出现时按钮仍全部禁用(实测 76 个),
// 此时点击会被 disabled 吞掉。等它恢复再点,否则量到的是"点了没反应"的假象。
await page.waitForFunction(() => {
const button = [...document.querySelectorAll("button")]
.find((b) => b.textContent.includes("自然语言修改适用范围"));
return button && !button.disabled;
}, {timeout: 15000}).catch(() => {});
const toggled = await page.evaluate(() => {
const button = [...document.querySelectorAll("button")]
.find((b) => b.textContent.includes("自然语言修改适用范围"));
if (!button) return "未找到按钮";
if (button.disabled) return "按钮仍禁用(busy 未解除)";
button.click();
return "clicked";
});
check("能点开候选行的修订入口", toggled === "clicked", toggled);
await wait(400);
const panels = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("形成草稿修订")).length);
// 同一个 card.id 在列表里有多行(草稿行与各版本行共享 id):
// 面板若按 card.id 记录"展开哪张卡",点一行会连开多行——真实浏览器里量到过 4 个。
check("展开修订面板只影响被点的那一行", panels === 1, `展开的修订面板数=${panels}`);
const saveDisabled = await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes("形成草稿修订"));
return button ? button.disabled : null;
});
check("未填写修订要求时「形成草稿修订」保持禁用", saveDisabled === true);
check("修订区声明草稿不直接生效", /不会自动发布/.test(afterExtract) || /不直接生效/.test(afterExtract));
// ---- 身份门禁:manager 不该看到发布按钮
const managerPublish = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("审核发布")).length);
check("manager 身份看不到「审核发布」按钮", managerPublish === 0, `数量=${managerPublish}`);
await shot(page, "05-experience-center.png", true);
// ---- 换 admin 身份:评审入口应出现,但发布仍需要 reviewer(BR-42 / AC-43)
// 没有用 reviewer 做这一步:演示身份里 reviewer 的 `/projects` 只授权到 DEMO-C,
// 而 DEMO-C 没有知识候选,浏览器里点不到「审核发布」——这是演示数据的边界,不是门禁本身。
consoleErrors.length = 0;
await page.evaluate(() => sessionStorage.setItem("xingbang-demo-role", "admin"));
await page.goto(`${APP}/experience-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
await page.waitForFunction(() => document.body.innerText.includes("③ 候选"), {timeout: 40000});
await wait(600);
const adminEvaluate = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("隔离评测")).length);
const adminPublish = await page.evaluate(() =>
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("审核发布")).length);
check("admin 身份出现「隔离评测」入口", adminEvaluate > 0, `数量=${adminEvaluate}`);
check("admin 身份仍不出现「审核发布」(发布限 reviewer)", adminPublish === 0, `数量=${adminPublish}`);
await shot(page, "06-experience-admin.png", true);
await page.evaluate((role) => sessionStorage.setItem("xingbang-demo-role", role), ROLE);
// ---- 浏览器控制台:第四批的缺陷 1–4 都是"只有浏览器能看见"的那一类
const errors = noisy(consoleErrors);
check("历史经验加工页控制台无脚本错误", errors.length === 0,
errors.slice(0, 3).map((t) => t.slice(0, 120)).join(" | "));
return errors;
}
(async () => {
fs.mkdirSync(OUT, {recursive: true});
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: "new",
args: ["--no-proxy-server", "--window-size=1440,1200"],
});
const page = await browser.newPage();
await page.setViewport({width: 1440, height: 1200});
const consoleErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push("pageerror: " + error.message));
// 应用从 sessionStorage 读身份:先落到同源页面再写,否则进目标页时身份还没生效
await page.goto(`${APP}/experience-center`, {waitUntil: "domcontentloaded"});
await page.evaluate((role) => sessionStorage.setItem("xingbang-demo-role", role), ROLE);
const report = {role: ROLE, project: PROJECT, sections: {}};
try {
consoleErrors.length = 0;
await sectionPrescreen(page, consoleErrors);
report.sections.prescreen = noisy(consoleErrors).map((text) => text.slice(0, 300));
} catch (error) {
check("AI 预审章节未抛异常", false, String(error).slice(0, 200));
}
try {
report.sections.experience = await sectionExperience(page, consoleErrors);
} catch (error) {
check("历史经验加工章节未抛异常", false, String(error).slice(0, 200));
}
await browser.close();
const failed = results.filter((item) => !item.ok);
fs.writeFileSync(path.join(OUT, "result.json"),
JSON.stringify({...report, results}, null, 2));
console.log(`\n截图与结果写入 ${OUT}`);
console.log(`总计 ${results.length} 项,失败 ${failed.length} 项`);
process.exit(failed.length ? 1 : 0);
})().catch((error) => {
console.error("E2E 运行失败:", error);
process.exit(2);
});