Files
mini-openclaw-v2/scripts/browser-e2e.cjs
qiaoqichaoandClaude Code 4720d72965 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>
2026-10-01 03:04:18 +08:00

179 lines
9.9 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.
/**
* 真实浏览器端到端验证(纠偏第四批新增;用于关闭 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);
});