From c880f40e4c28f05e085bcb0bfe1018052e43af02 Mon Sep 17 00:00:00 2001 From: qiaoqichao Date: Thu, 1 Oct 2026 09:35:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(v2):=20=E4=BF=AE=E6=8E=89=E9=A2=84=E5=AE=A1?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=B8=8D=E5=88=B7=E6=96=B0=E4=B8=8E=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=A1=8C=E5=90=8C=20id=20=E8=BF=9E=E5=BC=80=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=E7=9A=84=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实 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 --- scripts/browser-e2e-modules.cjs | 439 +++++++++++++++++++ scripts/test_experience_center_ui.cjs | 8 + scripts/test_prescreen_ui.cjs | 15 + src/components/xingbang/ExperienceCenter.tsx | 16 +- src/components/xingbang/PrescreenPanel.tsx | 26 +- 5 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 scripts/browser-e2e-modules.cjs diff --git a/scripts/browser-e2e-modules.cjs b/scripts/browser-e2e-modules.cjs new file mode 100644 index 0000000..62e1615 --- /dev/null +++ b/scripts/browser-e2e-modules.cjs @@ -0,0 +1,439 @@ +/** + * 真实浏览器端到端验证(第二篇):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); +}); diff --git a/scripts/test_experience_center_ui.cjs b/scripts/test_experience_center_ui.cjs index 79d6119..65ff8ca 100644 --- a/scripts/test_experience_center_ui.cjs +++ b/scripts/test_experience_center_ui.cjs @@ -74,4 +74,12 @@ for (const endpoint of ["case-timeline", "knowledge-extractions", "knowledge-can } assert.match(panel, /PRESCREEN/, "必须走既有 prescreen 命名空间"); +// 7. 候选行的标识必须唯一:列表里同一个 card.id 会出现多行(草稿行与版本行共享 id) +// 只用 card.id 作 key 会撞 React key,作展开标记会一点连开多行——两者都在真实浏览器里量到过 +// (见 17 四批交付记录「浏览器验证发现的问题(第二篇)」)。 +assert.match(panel, /const rowKey = \(card: any\) =>/, "候选行必须有行级标识"); +assert.match(panel, /key=\{rowKey\(card\)\}/, "候选行的 React key 不得只用 card.id"); +assert.ok(!/setReviseFor\([^)]*card\.id\b/.test(panel), "修订面板标记不得只用 card.id(会同开多行)"); +assert.ok(!/setCasesFor\([^)]*card\.id\b/.test(panel), "测试样本面板标记不得只用 card.id"); + console.log("experience-center-ui: PASS"); diff --git a/scripts/test_prescreen_ui.cjs b/scripts/test_prescreen_ui.cjs index 861353e..bfea146 100644 --- a/scripts/test_prescreen_ui.cjs +++ b/scripts/test_prescreen_ui.cjs @@ -99,4 +99,19 @@ assert.match(panel, /domain\.text/, "数据域必须以文字呈现"); assert.match(lib, /雷达面积不换算成百分比|STAGE_LEGEND/, "必须说明阶段与概率无关"); assert.ok(!/probability\s*[:=]\s*[0-9]/.test(panel), "面板不得写入任何概率数值"); +// 11. 从本页发起的运行必须能被面板读到(真实浏览器端到端验证发现的缺陷) +// 面板原先只依赖 project.id 重载,于是从本页发起预审、运行跑完后,面板仍停在旧运行上, +// 新运行也进不了"查看哪一次运行"——而徽标写着"结果会自动刷新"。 +// 源码守卫与生产构建都看不出这类问题,只有真渲染会暴露;这里锁住修好的依赖关系。 +assert.match(panel, /const runKeys = \(project\.runs \|\| \[\]\)/, + "面板必须从项目的运行集合推导重载信号"); +assert.match(panel, /\[project\.id, runKeys\]/, + "运行集合变化时面板必须重载,否则发起的预审不会被读到"); +assert.ok(!/\[project\.id\]\)/.test(panel), + "不得退回只依赖 project.id 的重载(发起的运行会读不到)"); +// run.status 与 prescreen_run 视图不是同一事务写入的:只读一次会读到视图还没落库的中间态 +assert.match(panel, /item\.id === newestRun/, + "重载前必须确认最新运行已出现在视图列表里,不能只赌一次"); +assert.match(panel, /rounds < 5/, "重试必须有次数上限,不得变成无限轮询"); + console.log("prescreen-ui: PASS"); diff --git a/src/components/xingbang/ExperienceCenter.tsx b/src/components/xingbang/ExperienceCenter.tsx index 705febb..b3ee77c 100644 --- a/src/components/xingbang/ExperienceCenter.tsx +++ b/src/components/xingbang/ExperienceCenter.tsx @@ -22,6 +22,12 @@ const ORIGIN_ICONS: Record = { const CARD_STATUS: Record = { draft: "草稿", published: "已发布", rejected: "已拒绝", withdrawn: "已撤回", }; +/** 候选行的稳定标识。列表里**同一个 card.id 会出现多行**(草稿行与各版本行共享 id, + * 见 knowledge_cards.list_cards),所以 React key 与"展开哪一行的面板"都不能只用 card.id: + * 只用 id 会撞 key,点一行的「自然语言修改适用范围」还会连开同 id 的所有行—— + * 这两点都是真实浏览器里量到的(见 17 四批交付记录「浏览器验证发现的问题(第二篇)」)。 + * (id, version, status) 在注册表里唯一:同一 (id, version) 的草稿行与版本行 status 不同。 */ +const rowKey = (card: any) => `${card.id}@${card.version}@${card.status}`; // 组方案时的基座核查项:与种子方案一致,保证新方案能独立跑通(不是"只放卡片"的半成品方案)。 const BASE_SKILLS = ["corporate.consistency", "corporate.cashflow", "corporate.assets", "corporate.coverage"]; @@ -180,7 +186,7 @@ export default function ExperienceCenter({project, identity, send}: any) {

修订只产生草稿,不直接生效;发布需要先通过隔离评测,并由另一位有发布资格的人操作。已发布版本不可覆盖,修订会升版本并保留父版本。

{!candidates.length &&

尚无知识候选

先提炼或人工新增一张候选卡片。

} - {candidates.map((card: any) =>
+ {candidates.map((card: any) =>
{card.title}

@@ -194,10 +200,10 @@ export default function ExperienceCenter({project, identity, send}: any) {

编辑人 {card.edited_by}{card.reviewer_id ? ` · 审核人 ${card.reviewer_id}` : ""}

- - {canReview && } @@ -212,7 +218,7 @@ export default function ExperienceCenter({project, identity, send}: any) { {canPublish && card.edited_by === identity?.id &&

本卡由你本人提议或编辑,因此不显示发布按钮:发布需要独立审核人(BR-42 / AC-43)。

} - {reviseFor === card.id &&
+ {reviseFor === rowKey(card) &&
} - {casesFor === card.id &&
+ {casesFor === rowKey(card) &&