Skip to content

ADR-060: E2E 测试策略(Playwright + 双模式 Fixture + 场景数值钩子)

状态: ✅ 已完成(Phase 0 / Phase 1 / Phase 2 / Phase 3,2026-07-07 提出并推进) 日期: 2026-07-07 关联: ADR-041(CI 自动检查,E2E 接入点)、AGENTS.md(测试路由与 npm run test:e2e 入口) 背景: 当前测试资产为 33 个 Vitest 单元 spec + 2 个 Playwright E2E specsmoke + env-sky)。结构呈「逻辑层铜墙铁壁、UI/E2E 层四面漏风」:算法/物理/换装/绑定契约单测覆盖厚,但关键用户旅程(模型加载、动作播放、换装、AR、截图导出)无 E2E,且 3D 渲染层无任何断言钩子,旧 env-sky 截图仅断言 data:image/png 前缀、未比对内容。本 ADR 锁定 E2E 工具选型、断言策略与分阶段落地路标,供多 AI 协同规划。


一、问题边界

1.1 现状清点

事实来源
前端框架Vite + 原生 TypeScript + Babylon.js 9.19.x + babylon-mmd(非 React/Vue,非 Three.js)frontend/AGENTS.md、本项目事实
桌面壳Wails v3(Go + WebView2)AGENTS.md 技术栈
开发地址Vite localhost:5173(DOM-only)、WebView2 调试端口 9222(全量)frontend/playwright.config.tse2e/wails-fixture.ts
E2E 框架Playwright 已接入,含双模式 fixture:vitePage(Chromium 打 5173,不依赖 Wails)+ wailsPageconnectOverCDP 连 9222,真·Wails 运行时)frontend/e2e/wails-fixture.ts
单元/集成33 个 Vitest spec,覆盖 xpbd/vmd/procedural-motion/beat/audio/env/material/model/outfit/bindings/shortcut 等frontend/src/__tests__/
既有截图钩子window.__capture() 已存在于 core/dev-hooks.ts:44(DEV 块内,基于 Babylon CreateScreenshotAsync;ADR-102 拆分后由 main.ts 迁出)frontend/src/core/dev-hooks.ts
文档纪律tests/*.py(AI 犯错追踪 + 链接校验,ADR-041 范畴)tests/
测试入口文档AGENTS.md 构建块已补 npm run test:e2e;路由表已加「写/维护 E2E 测试 → frontend/e2e/ + playwright.config.ts」AGENTS.md(2026-07-07 增补)

1.2 痛点

  • E2E 覆盖薄:仅冒烟 + 环境天空面板 DOM;模型加载/动作播放/换装/AR/截图导出零覆盖。
  • 3D 渲染不可断言:无数值钩子,正确性只能靠人眼或脆弱的像素比对。
  • 外部误导风险:通用「Wails E2E 指南」常以 Three.js / DOM 密集型应用为前提,给出 localhost:34115(Wails v2 端口)与 getContext('2d') 截图哈希——对本项目均不适用(见风险表)。

1.3 断言策略之争

策略描述优点缺点
A. 场景数值断言(本 ADR 选)window.__scenefps/meshCount/constraintCount稳定、抗噪、不怕 UI 微调需先埋钩子(Phase 0 已完成)
B. 像素截图比对截全屏与 golden 图 diff能抓视觉回归对 WebGL 抗噪差、CI 慢、阈值难调;仅作次级基线
C. 纯 DOM 断言toBeVisible()/text=开始简单对 Babylon canvas 无效——画面里无 DOM 节点

二、方案设计

2.1 核心决策

  1. 工具:沿用 Playwright(不引入 Cypress / Go-Rod)。理由:社区主流、AI 生成/维护支持好、双模式 fixture 已建成。
  2. 断言分层
    • DOM 层(菜单/滑块/overlay)→ Playwright locator,vitePage(5173,快、不依赖 Wails)。
    • 3D 层(模型是否真渲染、物理是否在跑、FPS)→ window.__scene 数值断言,必要时辅以 window.__capture() 截图做次级基线。
  3. 运行纪律:Vitest 为默认回归(每次改逻辑跑);E2E 仅在 UI/菜单级改动时跑(需 wails3 dev 或 5173+9222 就绪)。已写入 AGENTS.md 注释。

2.2 window.__scene 数值钩子(Phase 0,已实现)

挂载于 frontend/src/core/dev-hooks.tsif (import.meta.env.DEV) 块内(ADR-102 拆分后由 main.ts 迁出;原 main.ts),复用已导入的 engine/scene 与已增补导入的 modelManager/focusedModel/applyOutfitVariant/loadOutfits

ts
(window as any).__scene = {
  get fps(): number { return engine.getFps(); },
  get meshCount(): number { return scene.meshes.length; },           // Babylon 扁平数组,含地面/辅助 mesh → 断言阈值
  get particleCount(): number {                                        // XPBD 粒子总数
    let n = 0;
    for (const c of modelManager.clothInstances.values()) n += c.solver?.particles.length ?? 0;
    return n;
  },
  get constraintCount(): number {                                      // XPBD 约束总数
    let n = 0;
    for (const c of modelManager.clothInstances.values()) n += c.solver?.constraints.length ?? 0;
    return n;
  },
  get currentAnimation(): string {
    // babylon-mmd 公开 API 无 mmdRuntime.runtimeAnimation;改用焦点模型动作名
    const inst = focusedModel();
    return inst?.vmdName ?? 'idle';
  },
  // 换装行为钩子(Phase 1):驱动真实 applyOutfitVariant 路径,避免 3-4 层脆弱菜单导航
  outfitVariants: (): Promise<{ variants: string[]; error: string | null }> => {
    const inst = focusedModel();
    if (!inst) return Promise.resolve({ variants: [], error: null });
    // 返回 {variants, error} 而非 .catch([]):区分「无换装」与「loadOutfits 失败」,
    // 避免后者被静默吞掉、掩盖真实回归。
    try {
      const o = await loadOutfits(inst.id);
      return { variants: (o?.variants ?? []).map((v) => v.name), error: null };
    } catch (e) {
      return { variants: [], error: String(e) };
    }
  },
  applyOutfit: (variantName: string): Promise<boolean> => {
    const inst = focusedModel();
    if (!inst) return Promise.resolve(false);
    return applyOutfitVariant(inst.id, variantName).then(() => true).catch(() => false);
  },
  // 当前帧 16x16 亮度指纹(Phase 2):浏览器内生成,避开 PNG 解码与 2D context
  fingerprint: async (): Promise<string> => {
    const url = await window.__capture!();
    const img = new Image(); img.src = url; await img.decode();
    const c = document.createElement('canvas'); c.width = c.height = 16;
    const ctx = c.getContext('2d'); if (!ctx) return '';
    ctx.drawImage(img, 0, 0, 16, 16);
    const d = ctx.getImageData(0, 0, 16, 16).data;
    let s = ''; for (let i = 0; i < d.length; i += 4) s += d[i] + d[i+1] + d[i+2] > 384 ? '1' : '0';
    return s;
  },
  capture: (): Promise<string> => window.__capture!(),                // 复用既有 Babylon 截图,不碰 2D context
};

真实导出路径(其他 AI 改此钩子时务必引用,勿套 Three.js 模板)

符号导出位置
scene / engine / modelManagerfrontend/src/scene/scene.ts
mmdRuntimefrontend/src/core/state.ts(经 core/config 再导出至 main.ts
focusedModel()frontend/src/scene/scene.ts函数,返回焦点 ModelInstance,用 focusedModel()?.id
applyOutfitVariant / loadOutfitsfrontend/src/scene/scene.ts 再导出自 frontend/src/outfit/outfit.ts
XpbdSolver.constraints / .particlesfrontend/src/physics/xpbd-solver.ts
ClothInstance.solverfrontend/src/physics/cloth-manager.ts
window.__capturefrontend/src/core/dev-hooks.ts:44(ADR-102 拆分后;原 main.ts:875)

三、详细实现(分阶段)

Phase 0 — 场景数值钩子(✅ 已完成 2026-07-07)

  • [x] core/dev-hooks.ts 新增 window.__scene(fps / meshCount / particleCount / constraintCount / currentAnimation / capture / fingerprint / outfitVariants / applyOutfit;ADR-102 拆分后由 main.ts 迁出)
  • [x] 复用既有 window.__capture不创建 2D-canvas 哈希(WebGL canvas getContext('2d') 返回 null
  • [x] ../scene/scene 导入补 modelManager
  • [x] npm run check 通过(tsc --noEmit exit 0)
  • [x] 中央文件改动已在当日 memory/YYYY-MM-DD.md 认领(项目多 AI 铁律)

Phase 1 — E2E 关键路径骨架(✅ 已完成 2026-07-07)

wails-fixture.ts 双模式,新增 3 个 spec(DOM 用 vitePage,WebGL 用 wailsPage):

  • [x] e2e/model-load.spec.ts — 默认模型 / 指定名模型 → waitForFunction(__scene.meshCount > 10) + fps >= 30

  • [x] e2e/action-play.spec.ts — 切动作 → 断言 __scene.currentAnimation 变化(非 idle);换装 → 经 __scene.outfitVariants()/applyOutfit() 驱动真实换装路径,比对 fingerprint() 前后变化,变体不足 2 个时 test.skip

  • [x] e2e/export-screenshot.spec.ts__scene.capture() 返回有效 PNG dataURL;场景菜单「截图当前模型」入口 DOM 可见

  • [x] 真实选择器(已 grep 锁定,勿凭空猜)

    入口选择器来源
    模型库#btnMainActioncore/dom.ts
    动作弹窗#btnMotionPopupcore/dom.ts
    场景菜单#btnScenecore/dom.ts
    环境面板#btnEnvcore/dom.ts
    菜单项div.slide-item(标签 span.slide-labelmenus/menu.ts / core/ui-slide-row.ts
    截图菜单项文本「截图当前模型」(scene:screenshot)menus/scene-menu.ts
    换装入口详情层「外观 → 服装变体」(buildOutfitLevel)menus/model-detail.ts:141 / menus/outfit-ui.ts
  • [x] 换装 E2E 策略决策:模型详情→服装变体是 3-4 层菜单导航(库 scene:<id> 行 → 详情 → 外观折叠 → 服装变体 → 变体行),DOM 定位极脆弱且无法在本环境验证。据本 ADR「数值/行为断言为主」原则,换装行为走 __scene.applyOutfit() 钩子(真实驱动 applyOutfitVariant,含 loadOutfits + mesh 重定向),仅对画面变化做指纹比对;纯 DOM 菜单路径不作为 E2E 主判据。

  • [x] npm run check + npx playwright test --list 通过(枚举成功;当前 spec 已扩展至 10 个,含 5 个面板 DOM spec,准确计数以 --list 为准)

导出截图说明:本项目截图走 Wails 原生 SaveFile 对话框(非浏览器 download 事件),Playwright 无法拦截。正确做法是断言 __scene.capture() 的 Babylon 管线 + 场景菜单入口 DOM(见 export-screenshot.spec.ts)。

Phase 2 — 截图基线比对(✅ 已完成 2026-07-07,指纹方案)

采用 粗粒度指纹基线 取代原始「golden PNG + 像素 diff」:

  • [x] window.__scene.fingerprint()(与 Phase 0 钩子同期落地于 core/dev-hooks.ts,ADR-102 拆分后由 main.ts 迁出;非独立扩展):Image.decode 解码 Babylon 截图 → 缩到 16×16 → 取每像素亮度阈值生成 256 位 0/1 字符串。避开的是对 WebGL canvas 调 getContext('2d')(返回 null)的陷阱;PNG 经解码后在新 canvas 上取 2D context 读像素。
  • [x] helpers.tscompareToBaseline(name, hash, tolerance=0.08) 用汉明距离比对;首次运行无基线自动生成(generator mode,CI seed 用),已存在则比对。
  • [x] e2e/__baselines__/(含 README.md):基线 JSON 落盘处;删除对应 .json 即可重算。
  • [x] env-sky.spec.ts「纯色纯白截图」升级为:校验 capture() 管线 + fingerprint() 与基线比对(容忍 0.08)。
  • [ ] 后续可扩:model-load 默认场景基线、动作切换前后基线 diff(按需)。

为何不用 data:image/png 字符串直接比对:Babylon CreateScreenshotAsync 压缩非确定性,同画面字符串可能不同;指纹方案对驱动/抗锯齿抖动稳健。

Phase 3 — CI 集成(✅ 已完成 2026-07-07,挂 ADR-041)

.github/workflows/ci.yml 落地「两层 E2E 门禁」,spec 用 Playwright 原生 tag(@dom / @webgl)切分,CI 以 --grep 过滤:

  • [x] e2e job(阻塞门禁,ubuntu-latest):仅跑 @domsmoke 3 + env-sky DOM-only 2 = 5 个)。Playwright 自带 Chromium 打 Vite 5173,不依赖 Wails 运行时,验证菜单/overlay/快捷键等 DOM 层回归。同一步内 npm run dev & 后台起 Vite → 轮询 5173 就绪 → npx playwright test --grep @dom → 收尾 kill。
    • env-sky @dom 断言已据真实 UI 修正:天空是统一层级(非「每模式一组滑块」),分段控件只显示当前模式(程序化/纯色/贴图),另有环境预设 chips(黎明/正午/夕阳/夜景/阴天/霓虹夜)与自定义颜色控件(天空色 R/G/B,非 input[type=range])。故断言改为:模式控件 + 预设 + 颜色控制均渲染、点击预设不报错。
  • [x] e2e-wails job(best-effort,windows-latestcontinue-on-error: trueneeds: e2e:跑 @webglwails3 dev 启动真实 WebView2、由 main.go 读取 MMCAR_DEBUG_PORT=9222 注入 --remote-debugging-port 开放 CDP(⚠️ 不可用 WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS——Wails v3 显式设 AdditionalBrowserArgs 会屏蔽该 env var,设了端口也开不了),connectOverCDP 连 9222 断言 3D 渲染。
  • [x] 平台约束(关键)connectOverCDP 是 Chromium 专用协议;Wails 在 Linux(ubuntu)用 WebKitGTK,其远程调试器不兼容 CDP,故 wailsPage 测试只能在 windows-latest(原生 WebView2)跑。e2e-wails 当前 continue-on-error翻为阻塞由独立 webgl-weekly.yml 承担(周日 cron / 手动触发、windows-latest、失败自动开 issue),覆盖与 e2e-wails 相同的 @webgl 集。
  • [x] E2E 失败归档:playwright-report/ 上传为 artifact(if: always())。
  • [x] Vitest 常驻:test-frontend job 每次提交必跑(秒级、无运行时)。

为何两层而非一层:单层 wails3 dev 在 ubuntu 上因 WebKit≠CDP 根本连不上,若强行全量阻塞会 100% 红。分层后:可靠 DOM 门禁(@dom)作为真·提交门禁;完整 3D 集成(@webgl)在正确平台(Windows)上跑且容忍 flake。

演化([2026-08-10]):2026-08-09 曾将全部 E2E job 迁出 push CI(headless 环境硬伤拖垮 CI);2026-08-10 以「分层 + 失败计数 gate」挂回 push 独立 workflow,杜绝「push 全绿但 E2E 从未跑」的假绿。详见 §六 CI 门禁更新描述。


四、决策对比

方案描述优点缺点结论
Playwright 双模式(本 ADR)vitePage(5173) + wailsPage(9222) + __scene 数值断言快/稳分层、AI 友好、已建成E2E 需 Wails 运行时采用
Cypress浏览器访问 dev server交互调试好同样依赖运行时;大型应用慢;AI 生态弱于 Playwright不采用
Go-Rod / chromedpGo 控 WebView纯 Go、与后端紧需处理 9222 调试端口等底层细节;frontend AI 不熟 Go不采用
Testim/Mabl 等平台商业 AI 测试低代码、自适应 UI复杂 3D 逻辑不灵活;商业成本不采用

断言策略结论:以 A. 数值断言为主(Phase 0 已落地),B. 截图基线为次级(Phase 2),C. 纯 DOM 仅限 overlay/menu(不用于 canvas 内容)。


五、实施路标(Checklist)

Phase 0: 场景数值钩子(✅ 2026-07-07 完成)

  • 见第三节 Phase 0 清单。

Phase 1: E2E 关键路径骨架(✅ 2026-07-07 完成)

  • [x] grep 真实菜单选择器(见第三节 Phase 1 表)
  • [x] 写 model-load / action-play / export-screenshot 三个 spec(含换装钩子方案)
  • [ ] 本地 wails3 dev 起 9222 后 npm run test:e2e 实跑验证(本环境无 Wails 运行时,仅 tsc + list 通过)

Phase 2: 截图基线(✅ 2026-07-07 完成,指纹方案)

  • [x] fingerprint() 钩子 + compareToBaseline() + __baselines__/ 自动基线

Phase 3: CI 接入(✅ 2026-07-07 完成,挂 ADR-041)

  • [x] ci.yml 新增 e2e(ubuntu,@dom 阻塞)+ e2e-wails(windows,@webglcontinue-on-error
  • [x] spec 加 @dom/@webgl tag,--grep 切分
  • [x] 平台约束记录:connectOverCDP 仅 Chromium,故 wailsPage 跑 Windows

六、风险与边界

风险等级缓解
套用 Three.js 模板(外部「Wails E2E 指南」常见)本 ADR 明确 Babylon 导出路径表;window.__scene 已按真实符号实现;多 AI 改钩子前先读本 ADR
WebGL canvas 用 getContext('2d') 返回 null 抛错禁用 2D 哈希;统一走 window.__capture(Babylon 截图)
端口误导(指南写 34115 为 Wails v2)本项目用 5173 + 9222;见 1.1
meshCount 含系统 mesh(地面/辅助)导致阈值误判断言阈值(如 > 10)而非精确值;换装用 fingerprint() 变化
E2E 重(需 Wails 运行时)拖慢日常默认跑 Vitest;E2E 仅 UI 改动时(AGENTS.md 注释)
焦点模型动作名来源(曾考虑 mmdRuntime.runtimeAnimation,但该字段不在 babylon-mmd 公开 API)改用 focusedModel()?.vmdName ?? 'idle',无版本耦合风险
换装菜单导航脆弱(库→详情→外观→服装变体,3-4 层)据本 ADR 分层断言原则,换装行为走 __scene.applyOutfit() 钩子(真实路径),不做 E2E DOM 导航;仅对画面做 fingerprint() 比对
截图「golden PNG 像素 diff」对 WebGL 噪点/压缩敏感Phase 2 改用 16×16 亮度指纹 + 汉明距离(tolerance 0.08),对驱动/抗锯齿抖动稳健;主判据仍是数值
原生 SaveFile 截图对话框不可被 download 事件拦截不拦截;断言 __scene.capture() 管线 + 菜单入口 DOM
connectOverCDP 仅 Chromium 兼容:Wails 在 Linux 用 WebKitGTK,远程调试器非 CDPwailsPage@webgl)测试只能在 windows-latest(原生 WebView2)跑;Linux ubuntu 仅跑 vitePage@dom)作阻塞门禁;详见 ADR-041 §4

边界

  • 本 ADR 不替换 Vitest 单元测试——算法/物理层继续用 Vitest(33 spec 已覆盖)。
  • 本 ADR 不引入 Cypress / Go-Rod / 商业 AI 测试平台。
  • 本 ADR 不修改 window.__scene 之外的生产代码路径;钩子仅在 import.meta.env.DEV 下挂载,生产构建剔除。
  • 本 ADR 不处理 模型内资源路径编码(属 ADR-057/058)。
  • 多 AI 协作:触碰 core/main.ts 等中央文件前,须先在当日 memory/YYYY-MM-DD.md 认领(见项目铁律)。

七、运行指南(Runbook)

权威运行手册见 frontend/e2e/README.md——含前置安装、本地各场景启动命令、报告查看、基线重置、CI 对照与常见失败排查。下文仅给速查。

场景前置命令(均在 frontend/覆盖
快速 DOM 回归(无需 Wails)Vite 5173 起好 + npx playwright install chromium终端A npm run dev -- --host 127.0.0.1 --port 5173;终端B npx playwright test --grep "@dom"@dom(当前 7 个 describe)
完整 3D 集成(需 Wails+WebView2)本地装 Wails CLI v3 + Windows WebView2终端A $env:MMCAR_DEBUG_PORT="9222"; wails3 dev(⚠️ 用 MMCAR_DEBUG_PORT 而非被 Wails v3 屏蔽的 WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS) ;终端B npx playwright test --grep "@webgl"(⚠️ wails dev 会解析到 v2 报 wails.json 缺失;标签须引号)@webgl(当前 5 个 describe)
全量(wails3 dev 就绪时)同上npx playwright test(或 npm run test:e2e;准确数见 npx playwright test --list当前 10 spec(@dom×7 + @webgl×5 describe)
报告npx playwright show-report(默认 :9323
单元(Vitest)npm run test33 spec

CI 门禁([2026-08-10 更新]):E2E 挂 push(独立 workflow .github/workflows/e2e-suite.yml,分支 main/master,与 CI 分开显示)。e2e-dom(ubuntu, @dom 纯 DOM, 阻塞 failed>0 即红) + e2e-web-smoke(ubuntu, @web smoke, 阻塞 failed>0 即红) + e2e-web-full(ubuntu, @web 全量, best-effort, gate failed>20 红) + e2e-wails(windows, @webgl, best-effort, gate failed>20 红)。历史「全败仍全绿」假绿由各 job 的 failure-count gate 根治(超阈值 exit 1)。webgl-weekly.yml(windows, @webgl, 阻塞, 周日 cron/手动, 失败开 issue) 保留为每周深度回归。准确计数以 npx playwright test --list 为准,详见 README §7 与 ADR-041 §4。


八、验证方式

  1. 钩子可用wails3 dev 起 9222 → Playwright wailsPage 打开应用 → page.evaluate(() => window.__scene.fps) 返回数值、meshCount > 0fingerprint() 返回 256 位串。
  2. 模型加载model-load.spec.ts 加载默认模型后 waitForFunction(__scene.meshCount > 10) 通过且 fps >= 30
  3. 动作/换装action-play.spec.ts 切换动作后 __scene.currentAnimation 变化;换装经 __scene.outfitVariants()/applyOutfit() 驱动后 fingerprint() 前后不同(变体<2 自动 skip)。
  4. 导出export-screenshot.spec.ts 断言 __scene.capture() 返回有效 PNG dataURL,且「截图当前模型」菜单入口可见(原生 SaveFile 对话框不拦截)。
  5. 截图基线env-sky.spec.ts 纯色白屏 fingerprint()__baselines__/env-sky-solid-white.json 比对(首次自动生成)。
  6. 回归npm run check && npm run test(Vitest)全绿;E2E 在 wails3 dev 就绪下 npm run test:e2e 通过。

九、相关 ADR

  • ADR-041 — CI 自动检查(E2E 接入点与失败归档挂此)
  • ADR-019 — XPBD 布料(约束/粒子即 __scene 数据源)
  • ADR-057 / ADR-058 — 资源路径编码(不在本 ADR 范畴)
  • AGENTS.md — 测试路由表与 npm run test:e2e 入口(2026-07-07 已补)