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