Files
mini-openclaw-v2/scripts/test_module_shell_ui.cjs
T
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

157 lines
10 KiB
JavaScript
Raw 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.
/**
* 纠偏第三批 · 模块壳与三入口源码守卫(BR-50;AC-53/54/55)。
*
* 这是**源码断言**,不是浏览器端到端测试(docs/v2/06-validation.md §2)。
* 它锁住四类可机械核对的约束:
*
* 1. **深色导航+浅色工作区**(AC-53):导航取原型 token,画布保持浅色。
* 2. **三个模块各有独立入口**(BR-50):AI 预审 / 历史经验加工 / 轨迹复盘,
* 各自有路由、各自渲染**复用**的组件——不是把面板塞进旧页当页签。
* 3. **防重复页面**(AC-55):已合并的旧页必须仍是重定向;不得复活。
* 4. **权限与数据隔离**(AC-55):导航按角色裁剪;模块以 ?project= 为上下文,
* 不把项目 id 硬编码,也不在前端自造授权。
*/
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const exists = (file) => fs.existsSync(path.join(root, file));
const navigation = read("src/components/xingbang/navigation.css");
const nav = read("src/components/xingbang/AgentNav.tsx");
const home = read("src/app/page.tsx");
// ---------------------------------------------------------------- 1. 深色导航
assert.match(navigation, /\.xb-agent-nav\{[^}]*background:#102B27/, "主导航应使用原型的深色 nav.bg #102B27");
assert.match(navigation, /\.xb-agent-nav a\.selected\{[^}]*background:#244D41/, "选中项应使用 nav.active #244D41");
assert.match(nav, /智审工作台/, "品牌区应显示「智审工作台」(原型文案)");
// 深色底的文字对比度**算出来**,而不是在文档里声称"应该够清楚"(G-6 机械约束优先)。
// 阈值取 WCAG AA 正文 4.5:1;四组配色都要过。
function luminance(hex) {
const channel = (value) => {
const c = value / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function contrast(foreground, background) {
const [a, b] = [luminance(foreground), luminance(background)];
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
const NAV_BG = "#102B27", NAV_ACTIVE = "#244D41";
for (const [label, fg, bg] of [
["未选中导航项", "#DCE9E3", NAV_BG],
["选中导航项", "#FFFFFF", NAV_ACTIVE],
["次要文字(muted)", "#B1C6BE", NAV_BG],
["品牌标题", "#F3F7F5", NAV_BG],
]) {
assert.ok(contrast(fg, bg) >= 4.5, `深色导航「${label}」对比度应 ≥ 4.5:1,实测 ${contrast(fg, bg).toFixed(2)}:1`);
}
assert.match(nav, /业务工作区/, "导航应保留「业务工作区」分组标签");
// 导航是深色,身份选择器在导航内必须用深色变体,否则白底白字
assert.match(nav, /<IdentitySelector[^>]*dark/, "导航内的身份选择器应使用深色变体");
// 画布仍是浅色:不要把整页刷黑
assert.match(read("src/app/data-access/workspace.css"), /\.ra-app\{min-height:100vh;background:#f5f7f9/, "工作区画布应保持浅色");
// 模块名不得被 flex 压缩折行:折行后「AI 预审」会断成两行,三个入口读不清。
// 这类缺陷只在浏览器里可见(源码与构建都正常),是第四批浏览器验证发现的,这里补锁。
assert.match(read("src/components/xingbang/navigation.css"),
/\.xb-agent-nav a\{[^}]*flex:0 0 auto[^}]*white-space:nowrap/,
"导航项必须 flex:0 0 auto + white-space:nowrap,避免模块名折行");
// ---------------------------------------------------------------- 2. 三个独立入口
const MODULES = [
{key: "prescreen", href: "/prescreen-workbench", label: "AI 预审", component: "PrescreenPanel"},
{key: "experience", href: "/experience-center", label: "历史经验加工", component: "ExperienceCenter"},
{key: "trajectory", href: "/trajectory-center", label: "轨迹复盘", component: "TrajectoryPanel"},
];
for (const item of MODULES) {
assert.ok(exists(`src/app${item.href}/page.tsx`), `${item.label} 应有独立路由 ${item.href}`);
assert.ok(nav.includes(`"${item.href}"`), `导航应提供 ${item.label} 入口`);
assert.ok(nav.includes(item.label), `导航入口文案应为「${item.label}」`);
const page = read(`src/app${item.href}/page.tsx`);
// 复用组件而不是在路由里再写一份实现:import 自 @/components
assert.match(page, /from "@\/components\/xingbang\//, `${item.label} 路由应复用既有组件`);
assert.match(page, /AgentNav/, `${item.label} 路由应复用深色导航`);
// 数据隔离:项目上下文来自 URL,不硬编码项目 id
assert.match(page, /new URLSearchParams\(location\.search\)\.get\("project"\)/, `${item.label} 的项目上下文应来自 ?project=`);
assert.ok(!/DEMO-[ABC]/.test(page), `${item.label} 不得硬编码演示项目 id`);
}
// 首页必须呈现三个入口(AC-53:不能只保留旧版落地页布局)
for (const item of MODULES) {
assert.ok(home.includes(item.href), `业务首页应提供 ${item.label} 入口卡片`);
}
assert.match(home, /MODULES/, "首页应以模块清单驱动入口卡片,而不是写死一段静态文案");
// 旧版落地页内容保留为独立入口,而不是被删掉
assert.ok(exists("src/app/assistant/page.tsx"), "原落地页内容应保留在 /assistant");
assert.match(read("src/app/assistant/page.tsx"), /BusinessAssistant/, "/assistant 应仍渲染业务助手");
// ---------------------------------------------------------------- 3. 防重复页面(AC-55)
// 已合并的旧页必须仍是重定向,不得被重新实现成独立页面。
for (const merged of ["trajectory", "review-workbench", "report-workbench", "collateral-workbench"]) {
assert.match(read(`src/app/${merged}/page.tsx`), /redirect\(/,
`已合并页 src/app/${merged} 必须保持重定向,不得复活`);
}
// 不得把预审面板搬到与既有禁名冲突的目录(那会把旧的 S1/T09 结构重新引入)
for (const banned of ["prescreen", "sandbox", "simulation", "import-batch"]) {
assert.ok(!exists(`src/app/${banned}`), `不得新建与既有面板重名的路由 src/app/${banned}`);
}
// 同一个面板组件被两个宿主复用时,必须是同一份实现(import 复用),不是复制粘贴的第二份
const hosts = ["src/app/xingbang-workbench/page.tsx", "src/app/prescreen-workbench/page.tsx"];
for (const host of hosts) {
assert.match(read(host), /import PrescreenPanel from "@\/components\/xingbang\/PrescreenPanel"/,
`${host} 应复用同一个 PrescreenPanel 组件`);
}
// ---------------------------------------------------------------- 4. 权限(AC-55)
assert.match(nav, /canConfigure/, "导航应按角色裁剪配置治理入口");
assert.match(nav, /request\.state|identity\?\.role/, "导航的角色判据应来自服务端身份而不是硬编码");
assert.ok(!/localStorage\.setItem\("xingbang-demo-role"/.test(nav), "导航不得自行设定演示身份");
// ---------------------------------------------------------------- 5. 轨迹复盘的可操作与诚实性(AC-54)
const trajectory = read("src/components/xingbang/TrajectoryPanel.tsx");
assert.match(trajectory, /failed/, "轨迹面板必须能显示失败步骤(失败保留)");
assert.match(trajectory, /不填 0|未记录/, "成本缺失时必须留空,不得填 0");
assert.ok(!/cost\s*[:=]\s*0\b/.test(trajectory), "不得把缺失成本写成 0");
// 纠偏第四批把"会议结果录入"并入了本面板,因此这里不能再只断言"整页只读"——
// 要分别锁死两件事:**回放只读**(不改运行、不改历史结果)与**结果只可追加**(不覆盖历史)。
assert.match(trajectory, /回放是只读的|只读回放/, "回放必须声明只读(第四批加的是追加,不是改写)");
assert.match(trajectory, /supersedes_id/, "会议结果更正必须指向旧记录(supersedes_id)");
assert.match(trajectory, /不覆盖|只\*\*追加\*\*/, "界面必须声明更正只追加、不覆盖历史");
// 判据落在"是否渲染推理字段"上,而不是禁用词本身——否则解释规则的注释会把守卫自己绊倒。
assert.ok(!/\.\s*(reasoning|reasoning_content|thoughts?|chain_of_thought)\b/i.test(trajectory),
"不得渲染模型隐藏推理字段");
// 服务端已声明轨迹不含内部推理,界面必须把它显示出来(而不是自己编一句)
assert.match(trajectory, /trajectory\.note|note\}/, "应展示服务端对轨迹范围的说明");
// AC-56:既读又写——只读列表不再是本面板的全部职责
assert.match(trajectory, /review-outcomes/, "轨迹面板应消费会议结果端点");
assert.match(trajectory, /"POST",\s*`\$\{PRESCREEN\}\$\{path\}\/review-outcomes`/,
"会议结果必须真的能录入(POST 到 review-outcomes)");
assert.match(trajectory, /raw_code/, "录入必须围绕原始结果码,不得自行推断通过或否决");
assert.match(trajectory, /不计入分母/, "未匹配的结果必须标明不计入分母(单列)");
// 预审模块必须真的挂载预审面板(否则"AI 预审入口"是空壳)
assert.match(read("src/app/prescreen-workbench/page.tsx"), /<PrescreenPanel /, "AI 预审模块应挂载预审面板");
// ---------------------------------------------------------------- 6. 前后端路径一致
// 界面调用的端点必须在后端真的声明过——否则"入口可点"只是连到 404。
const prescreenApi = read("backend/api/prescreen.py");
for (const route of ['"/projects/{project_id}/runs/{run_id}/trajectory"',
'"/projects/{project_id}/review-outcomes"',
'"/projects/{project_id}/improvements"',
'"/improvements/{candidate_id}/transitions"']) {
assert.ok(prescreenApi.includes(route), `后端应声明路由 ${route}`);
}
assert.ok(read("backend/api/project_assistant.py").includes('"/projects/{project_id}/runs"'),
"后端应声明发起运行的路由");
// ---------------------------------------------------------------- 7. 既有禁令继续有效
for (const [name, source] of [["home", home], ["nav", nav], ["trajectory", trajectory]]) {
assert.ok(!/0\.(42|58|51|64)\b/.test(source), `${name} 不得硬编码演示概率值`);
assert.ok(!/"(00|10|01|11)"\s*:/.test(source), `${name} 不得复制演示情景表`);
}
console.log("module-shell-ui: PASS");