真实 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>
440 lines
24 KiB
JavaScript
440 lines
24 KiB
JavaScript
/**
|
||
* 真实浏览器端到端验证(第二篇):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);
|
||
});
|