fix(v2): 浏览器端到端验证——修掉模块页在浏览器里渲染不出内容的缺陷
首次用真实 Chrome 打开 /trajectory-center(新增可选脚本 scripts/browser-e2e.cjs),
暴露 5 个源码守卫与生产构建都看不出的缺陷,全部修复:
1. 三个模块页读 `list.items`,而 /projects 返回的是 {projects, identity}——
项目列表恒为空,prescreen-workbench / experience-center / trajectory-center
整页渲染不出任何面板,只显示"当前身份没有可访问的项目"这种看着正常的空态。
(第二/三批引入)改为 list.projects。
2. TrajectoryPanel 没被传 identity,canRecord 恒假 → 会议结果录入表单永不出现。
3. ImprovementPanel 草稿下拉以组件 id 作 key/value,同一组件多版本草稿时撞 React key,
且选不准要发布哪一版;改以 `组件id@版本` 为键值。
4. JSX 文本里的 `**强调**` 会原样显示星号(JSX 不渲染 Markdown),共 9 处;
改为 <b>(字符串上下文处直接去掉标记)。
5. 深色导航模块名被 flex 压缩折行("AI 预审"断成两行);
加 flex:0 0 auto + white-space:nowrap。
另修一个既有测试脆弱点:test_trajectory.py 的更正用例断言"哪条排前面",
两条记录同刻度时排序翻转,曾让 verify.py 偶发失败;改为断言"两条都在、更正指向旧记录"。
回归防线:第 5 项补进 test_module_shell_ui.cjs 源码断言;
test_ui_readability.cjs 的导航字号断言改为不把 min-height 钉在规则首位。
验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),
证据 test-evidence/20261001-030127-ff59d7e/;scripts/browser-e2e.cjs 19/19 PASS
(截图在 test-evidence/e2e-batch4/)。该 E2E 脚本刻意不接入 verify.py。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 真实浏览器端到端验证(纠偏第四批新增;用于关闭 F11)
|
||||
*
|
||||
* 用系统已安装的 Chrome 驱动真实界面:深色导航 → 轨迹复盘 → 会议结果录入 → 改进对照面板。
|
||||
* 它补的是 `scripts/test_*.cjs` 那批**源码字符串断言**补不上的那一层——第四批靠它抓到若干
|
||||
* 源码守卫与生产构建都看不出的缺陷(详见 17 四批交付记录「浏览器验证发现的问题」)。
|
||||
*
|
||||
* **刻意不接入 `scripts/verify.py`**:verify 是本机随时可跑的强制门禁,不应依赖浏览器
|
||||
* 是否存在。本脚本是可选证据,需要时手动跑。
|
||||
*
|
||||
* 依赖与用法:
|
||||
* 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.cjs # 截图与 result.json 落在 E2E_OUT
|
||||
*
|
||||
* 注意:脚本会**真的写入**演示库(新增会议结果),不是只读冒烟。
|
||||
*/
|
||||
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");
|
||||
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 });
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
(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", (msg) => {
|
||||
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||||
});
|
||||
page.on("pageerror", (err) => consoleErrors.push("pageerror: " + err.message));
|
||||
|
||||
// 先落到同源页面,写入演示身份,再进目标页(应用从 sessionStorage 读身份)
|
||||
await page.goto(`${APP}/experience-center`, {waitUntil: "domcontentloaded"});
|
||||
await page.evaluate((role) => sessionStorage.setItem("xingbang-demo-role", role), ROLE);
|
||||
|
||||
await page.goto(`${APP}/trajectory-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
|
||||
// 面板是异步取数后渲染的:必须等它真的出现,否则断言抓的是渲染前的空壳
|
||||
await page.waitForFunction(
|
||||
() => document.body.innerText.includes("运行轨迹回放")
|
||||
&& document.body.innerText.includes("改进对照与发布"),
|
||||
{timeout: 30000});
|
||||
|
||||
// ---------------------------------------------------------------- 深色导航与三入口(AC-53)
|
||||
const nav = await page.evaluate(() => {
|
||||
const el = document.querySelector("nav.xb-agent-nav");
|
||||
if (!el) return null;
|
||||
return {bg: getComputedStyle(el).backgroundColor,
|
||||
items: [...el.querySelectorAll("a")].map((a) => a.textContent.trim())};
|
||||
});
|
||||
check("深色导航存在且为深色底", Boolean(nav) && nav.bg === "rgb(16, 43, 39)",
|
||||
nav ? `bg=${nav.bg}` : "未找到 nav.xb-agent-nav");
|
||||
for (const label of ["AI 预审", "历史经验加工", "轨迹复盘"]) {
|
||||
check(`导航含「${label}」入口`, Boolean(nav) && nav.items.includes(label));
|
||||
}
|
||||
// 模块名不得被 flex 压缩折行(折行后 "AI 预审" 会断成两行,三个入口读不清)
|
||||
const navHeights = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("nav.xb-agent-nav a")]
|
||||
.map((a) => ({text: a.textContent.trim(), h: Math.round(a.getBoundingClientRect().height)})));
|
||||
const wrapped = navHeights.filter((item) => item.h > 50);
|
||||
check("导航项文字不折行", wrapped.length === 0, JSON.stringify(wrapped));
|
||||
|
||||
// ---------------------------------------------------------------- 轨迹复盘面板(AC-54)
|
||||
const heading = await page.evaluate(() =>
|
||||
document.querySelector("main h1") && document.querySelector("main h1").textContent.trim());
|
||||
check("轨迹复盘标题渲染", heading === "轨迹复盘", `h1=${heading}`);
|
||||
|
||||
const panelText = await page.evaluate(() => document.body.innerText);
|
||||
check("轨迹面板声明回放只读", /回放是只读的|只读回放/.test(panelText));
|
||||
check("会议结果区声明更正只追加不覆盖", /不覆盖/.test(panelText));
|
||||
check("未匹配标注不计入分母", /不计入分母/.test(panelText));
|
||||
check("改进对照面板渲染", /改进对照与发布/.test(panelText));
|
||||
check("改进面板声明版本号不由界面提交", /版本号不由界面提交/.test(panelText));
|
||||
|
||||
// ---------------------------------------------------------------- AC-56:真实录入一条会议结果
|
||||
await page.evaluate(() => {
|
||||
const node = [...document.querySelectorAll("h4")].find((h) => h.textContent.includes("追加会议结果"));
|
||||
if (node) node.scrollIntoView({block: "center"});
|
||||
});
|
||||
const stamp = Date.now().toString().slice(-6);
|
||||
const meetingId = `E2E-${stamp}`;
|
||||
|
||||
await page.type('input[aria-label="原始结果码"]', "附条件通过");
|
||||
await page.type('input[aria-label="会议编号"]', meetingId);
|
||||
await page.screenshot({path: path.join(OUT, "01-outcome-form.png")});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("button")]
|
||||
.find((b) => b.textContent.includes("保存会议结果"));
|
||||
button.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(id) => document.body.innerText.includes(id), {timeout: 15000}, meetingId
|
||||
).catch(() => {});
|
||||
|
||||
const afterSave = await page.evaluate(() => document.body.innerText);
|
||||
check("录入的会议结果出现在列表里", afterSave.includes(meetingId), `meeting_id=${meetingId}`);
|
||||
// 只看 role="status" 的提示条:整页文本里到处都有"未匹配",用它判断会把任何页面都判成通过
|
||||
const notice = await page.evaluate(() => {
|
||||
const el = document.querySelector('[role="status"]');
|
||||
return el ? el.textContent.trim() : "";
|
||||
});
|
||||
check("服务端返回未匹配时给出提示", /未匹配/.test(notice), `notice=${notice}`);
|
||||
|
||||
// ---------------------------------------------------------------- AC-57:改进面板的权限门
|
||||
const publishButtons = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("button")].filter((b) => b.textContent.includes("审核发布")).length);
|
||||
const releasedBlocks = await page.evaluate(() =>
|
||||
(document.body.innerText.match(/已发布真实版本/g) || []).length);
|
||||
check("改进面板渲染(有候选或空态)", /尚未改进候选|改进候选|拟发布/.test(afterSave));
|
||||
const buttonTexts = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("button")].map((b) => b.textContent));
|
||||
check("不存在自动上线类按钮",
|
||||
!buttonTexts.some((text) => /自动上线|一键|自动发布/.test(text)));
|
||||
console.log(` (当前「审核发布」按钮 ${publishButtons} 个;已发布标记 ${releasedBlocks} 处)`);
|
||||
|
||||
// ---------------------------------------------------------------- 结论:先结算已登录阶段
|
||||
const noisy = (list) => list.filter((text) => !/favicon|404 \(Not Found\)/i.test(text));
|
||||
const authenticatedErrors = noisy(consoleErrors).slice();
|
||||
check("已登录阶段控制台无脚本错误", authenticatedErrors.length === 0,
|
||||
authenticatedErrors.slice(0, 2).map((t) => t.slice(0, 90)).join(" | "));
|
||||
|
||||
// ---------------------------------------------------------------- 无有效身份:界面不假装可写
|
||||
consoleErrors.length = 0;
|
||||
await page.evaluate(() => sessionStorage.setItem("xingbang-demo-role", "preview-none"));
|
||||
await page.goto(`${APP}/trajectory-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const anonymousText = await page.evaluate(() => document.body.innerText);
|
||||
const anonymousInputs = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("input")].map((i) => i.getAttribute("aria-label")).filter(Boolean));
|
||||
check("无有效身份时不显示会议结果录入表单",
|
||||
!anonymousInputs.includes("原始结果码"), `可见输入框=${JSON.stringify(anonymousInputs)}`);
|
||||
// 无效身份下项目列表也取不到,面板根本不挂载——这与"显示只读说明"都是不假装可写
|
||||
check("无有效身份时给出如实说明(只读提示或无可访问项目空态)",
|
||||
/只读查看|需要记录人身份|没有可访问的项目/.test(anonymousText));
|
||||
check("后端对无效演示身份返回 401(界面没有绕过)",
|
||||
consoleErrors.some((text) => /401/.test(text)));
|
||||
await page.screenshot({path: path.join(OUT, "02-anonymous.png")});
|
||||
|
||||
await page.evaluate((role) => sessionStorage.setItem("xingbang-demo-role", role), ROLE);
|
||||
await page.goto(`${APP}/trajectory-center?project=${PROJECT}`, {waitUntil: "networkidle2"});
|
||||
await page.waitForFunction(() => document.body.innerText.includes("运行轨迹回放"), {timeout: 30000});
|
||||
await page.screenshot({path: path.join(OUT, "03-trajectory-center-full.png"), fullPage: true});
|
||||
|
||||
await browser.close();
|
||||
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
fs.writeFileSync(path.join(OUT, "result.json"),
|
||||
JSON.stringify({role: ROLE, project: PROJECT, meetingId,
|
||||
authenticatedConsoleErrors: authenticatedErrors.map((t) => t.slice(0, 300)),
|
||||
results}, null, 2));
|
||||
console.log(`\n截图与结果写入 ${OUT}`);
|
||||
console.log(`总计 ${results.length} 项,失败 ${failed.length} 项`);
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
})().catch((err) => {
|
||||
console.error("E2E 运行失败:", err);
|
||||
process.exit(2);
|
||||
});
|
||||
Reference in New Issue
Block a user