跳转至

macOS、Windows 和 Linux 上的桌面 UI 端到端测试

状态:等待负责人审阅的草案 · 2026-10-17 · 分支 test/electron-ui-e2e

为什么

CLAUDE.md 要求默认功能在 macOS、Windows 和 Linux 上表现一致,并将任何不一致视为 P0。目前没有任何检查在真实桌面窗口层面、针对拉取请求(PR)验证这一点:

层 Runner 数量 是否在 CI 中运行
渲染器冒烟测试:由 Vite 提供服务的 UI 运行在 Chromium 中,并使用模拟的 window.voicestudio 桥 Playwright chromium ~70 4,仅 Linux(ci.yml 中的 “Electron renderer workflow smokes”)
真实 Electron 应用,未打包(electron/out/main/index.js) Playwright _electron ~11 个 native-*-smoke 无
打包安装程序的启动 + 设置 Playwright _electron(packaged-smoke.mjs) 1 macOS、Windows、Linux,但仅在手动预演、发布标签和 Microsoft Store 运行时

因此,macOS 或 Windows 上的 preload、IPC、主进程、user-data-path 和窗口回归问题要到发布时才会暴露,而且大约 66 个渲染器冒烟测试从未运行。渲染器冒烟测试还伪造了 platform: 'win32',因此它们永远看不到真实的操作系统。

目标

  1. 三个操作系统上每个 PR 的桌面门禁。 新增的 ci.yml 任务在 macos-15、windows-2022 和 ubuntu-24.04 上使用 Playwright _electron 启动真实(未打包的)Electron 应用,并运行一套共享的、与操作系统无关的测试套件。
  2. 运行所有可以离线运行的渲染器冒烟测试,在 Linux 上运行一次,通过规则而不是硬编码列表来选择,这样新增的冒烟测试不会在无人运行的情况下悄然遗漏。
  3. 可调试的失败:每个失败的操作系统都会上传 Playwright trace、截图和主进程日志。

非目标

  • 打包安装程序测试。packaged-smoke.mjs 继续保留在预演/发布/商店流程中。
  • 真实推理、Python 后端、模型下载、GPU。测试套件使用假后端。
  • Rust 原生辅助程序(听写插入、采集、全局快捷键)。它只在打包时构建;需要它的测试继续保留在原来的位置。
  • 真实操作系统对话框、麦克风权限提示、Gatekeeper/SmartScreen。对话框在主进程中被 stub;其余部分保持手动测试。
  • 像素/截图对比。字体和 DPI 因操作系统而异;截图只是失败时的证据,而不是断言。
  • Windows ARM64 和 Intel macOS 测试环境(成本;打包预演会覆盖它们)。

反目标(即使全绿也算失败)

  • 不稳定的门禁。 一个偶尔失败的测试会让人养成重跑并忽略它的习惯。禁止固定睡眠;要等待条件满足。重试不是修复。
  • 针对默认行为的每操作系统预期。 测试套件对三个操作系统都断言同一种行为。禁止使用 if (process.platform …) 来改变用户可见的预期结果;它只能用来调整测试如何驱动操作系统(可执行文件路径、xvfb-run)。真正的行为差异属于产品缺陷。
  • 为了可测试性而削弱应用。 禁止在应用代码中禁用 Chromium 沙箱、上下文隔离或 CSP。测试钩子通过主进程已经认可的 env vars(如下)或新增的、除非设置否则无效的 env vars 来传递。
  • 网络泄漏。 测试套件不得访问互联网。

约束

  • 复用已固定的内容:根目录 playwright(^1.63)、来自 electron/node_modules 的 Electron、node --test 作为 runner。除非构建者证明该库无法完成某项必需操作,否则不要添加 @playwright/test(一旦需要:重新生成 bun.lock,并验证 bun install --frozen-lockfile)。
  • 每个远程操作都固定到提交 SHA(CONTRIBUTING → CI 供应链)。
  • 启动模式已在 native-watch-ui-smoke.mjs 中验证: electron.launch({ executablePath, args: [out/main/index.js, --user-data-dir=<tmp>], env: { ELECTRON_RENDERER_URL: '', OMNIVOICE_PORT: <fake>, VOICESTUDIO_SKIP_BACKEND: '1', VOICESTUDIO_ALLOW_MULTIPLE_INSTANCES: '1' } }),并使用来自 fake-health-backend.mjs 的 startHealthBackend()。同时设置 VOICESTUDIO_DISABLE_UPDATER=1。
  • 每个测试使用全新的临时 --user-data-dir;在 finally 中删除。
  • 文档同步:.github/CONTRIBUTING.md(质量门禁 / 运行测试)在同一 PR 中更新。
  • 保持主分支绿色:PR 必须通过完整的现有 CI 矩阵以及新任务。

设计

Part A:共享桌面测试套件(electron-ui 任务,3 个操作系统)

新文件 electron/tests/desktop-ui.test.mjs(node:test),外加一个小助手 electron/tests/desktop-harness.mjs,后者负责:按操作系统解析 Electron 可执行文件、启动假后端、使用临时 profile 启动、启动 context.tracing(截图 + 快照)、收集 pageerror、控制台错误和请求,并在失败时将 trace、截图和应用主日志保存到 electron/test-results/<os>/ 中。

测试用例所依赖的事实(已在代码中验证):

  • 渲染器通过应用的自定义协议(electron/src/main/protocol.ts)提供服务,并使用 TanStack createHashHistory(electron/src/renderer/src/router.tsx)进行路由,因此通过 location.hash = '#' + path 即可到达某个路由。
  • 首次运行状态位于渲染器 localStorage:voicestudio.setup.complete.v1(lib/setup-progress.ts)和 voicestudio.locale。除用例 2 外,其他测试都会像渲染器冒烟测试一样,用 context.addInitScript 预置这两项。
  • 假后端用 prompted: true 回答 /api/settings/analytics,从而抑制同意提示;用例 2b 会覆盖这一行为。
  • 保存对话框通过 window.voicestudio.files.saveData / saveAudio(ipc.ts 中的 filesSaveData / filesSaveAudio)触发。
  • star 徽章请求由渲染器发起(components/app-shell/github-star.tsx)。
  • 主进程错误会记录到 <userData>/main-process-errors.json(index.ts);日志位于 app.getPath('logs')。
  • test-results/ 已被 gitignore。

临时 profile 路径包含空格和非 ASCII 拉丁字符(例如 VoiceStudio Tëst <random>),这样 Windows 和 macOS 上 user-data 处理中的路径编码缺陷就会暴露出来。CJK 文本不进入测试(本地化规则)。

测试用例,在每个操作系统上预期完全一致:

  1. 启动与桥:第一个窗口打开;标题为 VoiceStudio;preload 桥 window.voicestudio 暴露其命名空间;window.voicestudio.app.platform 等于真实的 process.platform(不能伪造)。
  2. 首次运行:使用全新 profile 且不预置任何内容时,会显示设置门禁。通过 UI 完成设置后会到达主页路由。 2b. 分析同意:将 fixture 覆盖为 prompted: false 后,同意提示会显示两个按钮。拒绝后应用仍可使用,并且不会发送任何分析请求;用例 6 的守卫会强制验证第二部分。
  3. 每个路由都能渲染:将路由列表从 route-layout-smoke.mjs 移到 electron/tests/fixtures/routes.mjs,并从两处共同导入。对每个路由,设置 hash 并等待该路由的主区域。不能出现 error boundary,也不能出现 pageerror。如果某个路由请求了假后端不存在的后端路径(404),则添加最小 fixture;绝不能跳过该路由。
  4. 设置能在重启后保留:通过 Settings 修改主题和语言,关闭应用,使用同一 profile 重新启动,断言两者都已持久化,并断言应用写入的每个文件都在给定的 --user-data-dir 下(真实主目录 profile 中不能有任何文件)。在跳过后端时,没有任何主进程持有的设置可被触达(runtime-preferences.json 只能在 setup_required 状态下写入),而 packaged-smoke.mjs 已经覆盖了该路径。
  5. 对话框 IPC 往返:像 native-media-picker-smoke.mjs 那样,通过 app.evaluate stub dialog.showOpenDialog / showSaveDialog。
  6. 像该 smoke 测试一样,通过 preload 桥驱动两者:用 files.authorizeModelsDirectory 打开,用 files.saveData 保存。特定于操作系统的风险在于 IPC 和主进程。UI 控件已由用例 3 和渲染器冒烟测试覆盖。
  7. 断言 stub 路径能够往返、保存的文件确实存在于磁盘上,并且取消对话框时返回取消值。
  8. 本地优先守卫:在整个运行过程中,每个请求都必须指向 app: 协议、file:、devtools:/data:/blob:,或任意端口上的回环主机(127.0.0.1、localhost、[::1])。这覆盖了假后端以及 VOICESTUDIO_ELECTRON_PROXY_PORT 上的应用后端代理。任何其他主机都导致测试失败。
  9. 在两个位置观察请求:渲染器请求使用 context.route('**'),在首次导航前安装;主进程请求通过 app.evaluate 使用 session.defaultSession.webRequest。
  10. 用预设的计数 stub star 数量请求;github.com 永远不会被整体加入白名单。
  11. 构建者必须消除“请求在钩子安装之前就已发出”的竞态,例如在 reload 之后对完整请求列表进行断言。
  12. 无意外错误:零 pageerror、零控制台 error 条目(除非是 harness 中明确注释的允许列表),并且 main-process-errors.json 中没有新增条目。

伪后端:为 startHealthBackend() 提供一个可选的逐测试 fixture 覆盖,并且只在这些测试需要时添加 fixture。保持与现有原生 smoke 测试兼容。

第 B 部分:所有离线 renderer 冒烟测试(Linux,现有作业)

将“Electron renderer workflow smokes”步骤中四行硬编码的 node electron/tests/… 替换为 node electron/tests/run-renderer-smokes.mjs,该脚本:

  • 发现 electron/tests/*smoke* 文件,并按内容而非名称对每个文件分类,因为像 convert-live-smoke 这样的名称是经过 mock 且可运行的:
  • 导入 Playwright 的 chromium 且不是 *-real-smoke → renderer(在此处运行;当前 42 个);
  • *-real-smoke → real-backend;
  • 导入 _electron → electron(原生/已打包;Part A 或发布路径);
  • 其他任何文件都必须出现在 runner 中一个显式的、带有注释的列表里;
  • 逐个运行 renderer smoke 测试,使用 CI 步骤已经启动的 smoke:server;打印通过/失败表格,并在任一失败时以非零状态退出。

node --test 检查(electron/tests/smoke-inventory.test.mjs)会在 electron/tests 中任何 *-smoke.* 文件未匹配任何类别时失败,因此新增的 smoke 要么会被 CI 运行,要么会被有意分类。

归类处理:当前失败的 smoke 会被修复。只有当某个 smoke 覆盖的功能已不存在时才会删除该 smoke,并且要在提交信息中说明原因。有些 smoke 可能需要超出 CI 步骤当前导出的四个环境变量(OMNIVOICE_PORT、VOICESTUDIO_UI_URL、PLAYWRIGHT_CHANNEL、HF_HUB_*)之外的其他变量。runner 会从 runner 内一个小的逐文件表中读取 smoke 所需的任何额外变量。需要真实后端的 smoke 会被重新分类,而不是用 hack 方式绕过。

交付:Part B 作为 Part A 之后的第二个 PR 推出。它采用相同的方案,基于 Part A 的分支再开分支。这样,大规模的分类处理就不会阻塞三操作系统门禁。

CI 作业形态

electron-ui:
  name: Desktop UI (${{ matrix.label }})
  strategy: { fail-fast: false, matrix: macos-15 / windows-2022 / ubuntu-24.04 }
  timeout-minutes: 20
  steps: checkout → setup-node 22 → setup-bun → bun cache →
         bun install --frozen-lockfile → bun run --cwd electron build →
         (Linux: xvfb-run -a) node --test electron/tests/desktop-ui.test.mjs →
         on failure: upload electron/test-results/ (pinned upload-artifact)

不需要 Python,不需要 uv,也不需要原生辅助构建。它会在每个 PR 以及推送到 main 时运行(没有路径过滤,与 ci.yml 其余部分一致)。

Linux runner:Ubuntu 24.04 限制非特权用户命名空间,这可能会阻止 Electron 的沙箱启动。该作业在启动前会运行 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0:这很廉价、幂等,并能避免令人困惑的首次失败。应用代码中绝不要使用 --no-sandbox。xvfb-run 随 GitHub 的 Ubuntu 镜像提供;构建者会在首次运行时确认这一点,并且只在缺少 xvfb 时才安装它。macOS 和 Windows 从 node_modules 运行 Electron。该副本没有隔离属性,因此 Gatekeeper 和 SmartScreen 不适用。

失败产物:electron/test-results/<os>/ 保存 trace zip、最终截图、app.getPath('logs') 的副本以及 main-process-errors.json。该目录已被 gitignore。

已由所有者决定

  • 在 macOS、Windows 和 Linux 上运行自动化 UI 测试(本请求要求的内容)。
  • 计划和规格说明放在一个新分支上,与 Microsoft Store 的工作分开。

构建者自行决定的事项(已标记,所有者可以否决)

  • 每个 PR 门禁使用未打包的应用;已打包的应用仍保留在发布路径。
  • 使用 playwright 库 + node --test,不引入新依赖。
  • 没有路径过滤;该作业在每个 PR 上运行。
  • 新作业在 main 上连续两周保持绿色之前,不会加入分支保护的必要检查;之后由所有者添加。
  • Renderer smoke 测试只在 Linux 上运行,因为它们测试的是基于 mock bridge 的 DOM 行为,而该行为不随 OS 变化;OS 相关行为由 Part A 负责。
  • 案例 2b(拒绝分析功能同意)以及特殊字符 profile 路径是原方案之外的补充。两者成本都很低,并且都在守护 CLAUDE.md 中的硬性规则:本地优先同意和跨平台一致性。
  • 案例 4 使用 renderer 持久化的设置,外加一项“不会在 userData 之外写入任何内容”的检查,因为没有后端时无法访问任何由 main 进程持有的设置。
  • Part B 是一个独立的后续 PR。
  • Linux 的 sysctl 步骤会预先运行,而不仅仅是在失败后运行。

完成条件

  • electron-ui 在 PR 上于三个 OS 上均为绿色,并且完整现有 CI 也为绿色。
  • 故意破坏 preload bridge、某个路由或设置持久化,会使该作业在每个 OS 上失败(在 Linux 上本地检查过一次,其余 OS 通过推理确认)。
  • 每个离线 renderer smoke 测试都会在 CI 中运行,并且 inventory 测试为其提供保障。
  • CONTRIBUTING 中记录如何在本地运行两套测试套件。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio