Insomnia 烟雾测试 POM 规范:Playwright Page Object Model 的设计与落地
本文聚焦 Insomnia 仓库中 packages/insomnia-smoke-test 包下的 Playwright Page Object Model(POM)约定文档,系统讲解其对象分层(InsomniaApp / Page / Component)、定位器(Locator)所有权与命名规范、API 命名规则,以及测试侧的标准用法。读完之后,你将掌握如何在桌面端 E2E 测试中构建稳定、可读、选择器安全的 POM 结构,并理解 Insomnia 如何用 fixture 链、进程重启与原生对话框 mock 把这套约定支撑起来。
1. POM 目录的定位:保持测试稳定、可读、选择器安全
packages/insomnia-smoke-test/playwright/pages/ 目录承载整个 Insomnia 桌面应用(Electron)E2E 烟雾测试的页面对象层,其约定文档 POM Conventions 开宗明义:
This directory contains the Insomnia smoke-test Page Object Model (POM). The goal is to keep tests stable, readable, and selector-safe.
这个目录直接服务于 tests/smoke/(每次 CI push 在 Ubuntu 上运行的主套件)、tests/critical/(发布时运行的关键路径测试)和 tests/migration/(数据迁移测试)三套测试,结构说明见 Smoke Test README。所有测试命令都从仓库根目录执行:
npm install
npm run test:smoke:dev # 运行全部 Smoke 测试(dev 模式,自动拉起 echo server 与 Vite dev server)
POM 的价值在于:Electron 桌面应用的 E2E 测试天然面临选择器漂移、动画竞态、原生对话框无法自动化等挑战,把"选择器知识"集中收敛到 POM 类中,测试文件只描述业务动作,是应对这些不稳定性的第一道防线。
2. 对象分层:根门面、Page 与 Component
约定文档的第一节定义了三层对象:
InsomniaApp:根门面(root facade),负责装配(wires)各个 page object 与共享组件;Page对象:路由级表面(route-level surface),例如项目页(project page);Component对象:可复用或有边界的 UI 区域,例如状态栏(statusbar)、标签栏(tabbar)、侧边栏(sidebar)。
根门面实现 的 JSDoc 中给出了当前 POM 的完整装配关系:
InsomniaApp (root)
├── .statusbar -> StatusbarComponent (footer, always visible)
├── .navigationSidebar -> NavigationSidebar (left-side tree, always visible)
├── .projectPage -> ProjectPage
│ └── .workspaceList -> WorkspaceListComponent
├── .workspacePage -> WorkspacePage
└── .preferencesPage -> PreferencesPage
└── .dataTab -> PreferencesDataTab
InsomniaApp 的构造函数接收 Playwright 的 Page 与 ElectronApplication,然后在 _initPageObjects() 中一次性实例化上述所有对象;page 与 app 以私有字段持有、通过只读 getter 暴露,外部无法重新赋值,而门面内部的 relaunch() 可以在进程重启后替换它们。
constructor(page: Page, app: ElectronApplication) {
this._page = page;
this._app = app;
this._initPageObjects();
}
值得注意的是,InsomniaApp 还承担了超出"UI 装配"的职责——两个与 Electron 进程生命周期相关的能力:
relaunch():复用 fixture 暂存(stash)的启动环境变量(包括INSOMNIA_DATA_PATH)关闭并重启 Electron 进程,磁盘状态(NeDB、secret store)在重启前后保持,重启后所有 page object 重新构建;launchClone(newDataPath, envOverrides):以新的数据路径(可叠加不同的INSOMNIA_SESSION环境变量)拉起第二个独立的 Insomnia 实例,用于多用户/多项目并存的场景。
这两个方法都依赖 playwright/test.ts 中 app fixture 把启动环境暂存到 ElectronApplication 实例上的 __launchEnv 字段,若未通过该 fixture 启动则 _unstash() 会直接抛错,从结构上保证了重启语义的一致性。
2.1 Page 的公共基类:BasePage
路由级 Page 并非各自为政,而是继承自 BasePage:
export class BasePage {
readonly statusbar: StatusbarComponent;
readonly exportModal: ExportModal;
constructor(readonly page: Page) {
this.statusbar = new StatusbarComponent(page);
this.exportModal = new ExportModal(page);
}
}
从源码结构看,BasePage 把"所有路由上都会出现"的两个 UI 区域固化为共享组件:底部状态栏与全局导出弹窗。这样任何 Page 对象(项目页、工作区页、偏好设置页)自动获得这两个组件的访问能力,避免了在每个 Page 中重复 new。
3. Page 与 Component 的拆分原则
约定文档第二节给出了四条拆分规则:
- 路由专属的工作流(route-specific workflows)放进
Page对象; - 可复用、或可独立测试的 UI 区域放进
Component; - 一个
Page组合(composes)它自己的组件; InsomniaApp组合顶层的 page/component 并暴露快捷方式;测试应优先使用 POM API,只有在 POM 尚未建模的"缺口"处才允许直接使用裸的page.getBy...。
仓库中的实际拆分与这几条规则严格对应。以 ProjectPage 为例:它声明了对应路由 /organization/:orgId/project/:projectId(文件列表视图),组合了两个"项目专属"的组件——WorkspaceListComponent(工作区文件列表)与 NavigationSidebar(项目导航侧边栏),再继承 BasePage 提供的状态栏与导出弹窗。
而 WorkspaceListComponent 是典型的有边界区域组件:它只负责"文件网格中某个工作区卡片"这一局部表面,暴露 openWorkspace(name) 与 openWorkspaceCardDropdown(workspaceName) 两个动作:
export class WorkspaceListComponent {
constructor(readonly page: Page) {}
get root(): Locator {
return this.page.getByTestId('workspace-grid');
}
workspaceLocator(name: string): Locator {
return this.root.getByLabel(name);
}
/** Open a workspace by clicking its name. */
async openWorkspace(name: string): Promise<void> {
await this.workspaceLocator(name).click();
}
}
再看 StatusbarComponent,它是"全应用常驻区域"组件:
export class StatusbarComponent {
constructor(readonly page: Page) {}
get root(): Locator {
return this.page.getByTestId('statusbar');
}
/** Open Insomnia Preferences via the statusbar preferences button. */
async openPreferences() {
await this.root.getByTestId('settings-button').click();
}
/** Open Insomnia Preferences via keyboard shortcut. */
async openPreferencesViaShortcut() {
const modifier = process.platform === 'darwin' ? 'Meta+,' : 'Control+,';
await this.page.press('body', modifier);
}
}
这个类很好地示范了"同一行为、多种触发方式"都应建模在 POM 中:openPreferences() 走 UI 点击,openPreferencesViaShortcut() 走键盘快捷键(macOS 为 Meta+,,其他平台为 Control+,),两者在测试里都是可替换的稳定入口。
实际测试中对 POM API 的使用方式(摘自 app.test.ts):
await insomnia.projectPage.importFixture('smoke-test-collection.yaml');
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('Smoke tests');
4. 定位器所有权与命名规范
这是约定文档中约束最严格的一节,逐条翻译并结合源码印证:
| 规则 | 含义 | 源码例证 |
|---|---|---|
每个 page/component 必须有 root getter |
定位的"锚点" | WorkspaceListComponent.root 返回 getByTestId('workspace-grid') |
| POM 必须自行管理其动作用到的全部定位器 | 选择器不散落在测试里 | StatusbarComponent.openPreferences() 内部的 settings-button 只在类内出现 |
需要参数的定位器用 xxxLocator(...) 方法命名 |
例如按名称取列表行 | WorkspaceListComponent.workspaceLocator(name)、NavigationSidebar.projectRow(projectName) |
| 单一固定元素直接暴露为 getter | 例如 root、plusButton |
NavigationSidebar.filterInput 返回 getByLabel('Projects filter') |
定位器尽量从 root 出发做作用域限定 |
减少同名元素碰撞 | this.root.getByTestId('sidebar-tab-projects') |
| 属于既有 POM 的定位器,测试不得重复声明 | 单一事实来源 | 测试文件中只见 insomnia.navigationSidebar.clickRequestOrFolder(...),不见裸选择器 |
NavigationSidebar(左侧项目导航树,root 为 getByTestId('global-navigation-sidebar'))是第 3 条规则的完整示范。它的参数化定位器按命名空间分层:
projectRow(projectName: string): Locator {
return this.navigationTree.getByTestId(`project-node-${projectName}`);
}
workspaceRow(workspaceName: string): Locator {
return this.root.getByTestId(`workspace-node-${workspaceName}`);
}
requestRow(requestOrGroupName: string, workspaceName?: string): Locator {
if (workspaceName) {
// If workspaceName is provided, scope the locator to that workspace's subtree
// to avoid collisions between workspaces
return this.root
.getByTestId(`request-node-${requestOrGroupName}`)
.and(this.page.locator(`[data-workspace="${workspaceName}"]`));
}
return this.root.getByTestId(`request-node-${requestOrGroupName}`);
}
其中 requestRow 额外演示了"作用域限定防碰撞"这条规则:当请求名在多个 workspace 中可能重名时,通过 .and() 叠加 [data-workspace="..."] 属性选择器把定位器收缩到指定工作区的子树内。
值得强调的是,POM 的选择器策略以 Playwright 推荐的基于角色/标签/测试 ID 的定位为主,而非脆弱的 CSS 路径:
getByTestId(...):statusbar、workspace-grid、project-node-${name}、request-node-${name}等data-testid,属于测试与应用 UI 之间的"契约";getByRole('button', { name: ... })/getByLabel(...):如getByRole('menuitemradio', { name: actionName })选取下拉菜单项,getByLabel('Workspace actions menu button')定位卡片操作按钮;- 仅对少数稳定 DOM 锚点使用 CSS:如
ProjectPage.root返回this.page.locator('.app')。
5. API 命名规范:动词开头、行为单一、断言留在测试
约定文档第四节的三条 API 规则:
- 动作方法用动词开头命名:
closeTab、openAddTabMenu; - POM 暴露动作与定位器,断言(assertions)留在测试文件中;
- 方法聚焦于单一行为。
从源码看,这套"动作进 POM、断言出 POM"的原则基本被遵守,且 POM 中的"断言"仅限于两类:
- 软等待校验:如
NavigationSidebar.clickRequestOrFolder末尾的await expect.soft(row).toHaveAttribute('data-selected', 'true')——这是对"点击确实生效"的即时确认,帮助尽早暴露交互失败,而不是把业务级断言写死在 POM 里; - 状态探测方法:如
isWorkspaceFocused(workspaceName)返回boolean,把判断交给调用方。
业务结论性断言(例如"删除后列表应只剩 N 项")都出现在 tests/ 目录的测试文件中,POM 保持无预期(expectation-free)。
6. 测试用法模式:每个测试一个门面实例
约定文档第五节给出的标准用法是"每个测试实例化一次,使用组合对象":
test('example test', async ({ insomnia }) => {
await insomnia.projectPage.createCollection();
await insomnia.tabbar.closeTab('New Request');
await expect.soft(insomnia.tabbar.tabLocator('foo')).toHaveAttribute('data-selected', 'true');
});
需要注意:示例中的 tabbar 是约定文档给出的命名范式示意;对照当前 insomnia-app.ts 的实际装配,InsomniaApp 暴露的组合对象为 statusbar、exportModal、navigationSidebar、projectPage、workspacePage、preferencesPage。其 JSDoc 中的真实示例是:
test('example test', async ({ insomnia }) => {
// Project operations
await insomnia.projectPage.importFixture('simple.yaml');
// Shared components (statusbar is always present)
await insomnia.statusbar.openPreferences();
// Preferences and export
await insomnia.preferencesPage.dataTab.exportProjectData('My Project');
});
insomnia fixture 本身的定义非常薄,位于 test.ts:
insomnia: async ({ app, page }, use) => {
const insomnia = new InsomniaApp(page, app);
await use(insomnia);
}
真正的工作在 fixture 依赖链中完成:
appfixture:调用launchInsomnia()启动 Electron,注入INSOMNIA_DATA_PATH(每测试独立的随机数据目录)、INSOMNIA_API_URL(本地 echo server,http://localhost:4010)等一整套环境变量;启动时额外设置PLAYWRIGHT: 'true',应用主进程据此启用测试钩子(例如消费queueOpenDialogResponse排队的假showOpenDialog响应,用来 mock 原生文件/目录选择框);dataPathfixture:生成随机数据路径,并预置sidebarFocusForCollections: false设置——注释解释了原因:该设置对真实用户默认开启,但会把侧边栏收缩到单个集合,破坏大量假定完整树可见的既有测试;pagefixture:取firstWindow()(插件窗口在主窗口did-finish-load之后才创建,因此firstWindow恒为主窗口),并挂接pageerror/console.error监听器,把渲染进程的报错显式打进测试输出;userConfigfixture:提供跳过引导、测试会话、公钥/私钥等账号态注入。
app fixture 的 teardown 也体现了 POM 生态对"进程卫生"的重视:relaunch() 或 launchClone() 产生的额外实例统一登记在 liveApps 集合中,teardown 时先尝试优雅 close()(带 5 秒超时),失败则用 killProcessTree() 递归杀进程树——注释指出,单纯 process.kill() 只能杀掉顶层 Electron 进程,其 GPU/utility/renderer 辅助进程会被重新挂到父进程下继续持有 stdio 管道,导致 worker 收不到 EOF 而在所有测试已通过的情况下仍挂死超时。
7. 实战印证:ProjectPage 如何把约定变成"稳定"
ProjectPage 是观察这些约定落地价值的最佳样本。它的路由专属动作(createProject、createCollection、importFixture、exportWorkspaceFromCard 等)全部集中在 Page 层,而跨区域的复用动作委托给组件(this.sidebar.selectProject(name)、this.exportModal.selectExportFormat(format))。
其稳定化手段有四种,全部内聚在 POM 中、对测试完全透明:
- 竞态容错:
closeProjectModalIfStuck()处理"创建项目对话框在应用已创建项目并跳转后仍不自行关闭"的已知应用侧竞态——先以 5 秒超时探测对话框是否自行隐藏,未隐藏则点击[data-test-id="project-modal-close-button"],并对可能出现的 "Unsaved changes" 二次确认做catch(() => {})容忍,最终以dialog.waitFor({ state: 'hidden' })作为唯一硬校验; - 可靠点击:
clickReliably(locator, timeout)先waitFor({ state: 'visible' })再click(),避免关闭动画的 backdrop 仍拦截点击(stale-backdrop); - 多路径导航:
navigateFromWorkspaceBreadcrumb()最多重试 4 次,优先点面包屑、不可见时回退到侧边栏项目行,每次重试间用waitForURL的 5 秒窗口判定是否已到达项目仪表盘路由; - 原生对话框 mock:
importFixture()不弹真实文件选择框,而是通过app.evaluate把 fixture 文本写入 Electron 剪贴板,走"从剪贴板导入"路径;exportWorkspaceFromCard()/cloneGitProjectIntoFolder()则先调用mockSaveDialogForFile/mockOpenDialogForDirectory(utils)预排队的假响应。
createGitSyncProject() 中还有一段注释值得注意:等待 "Create or update dialog" 隐藏时特意用带名称的 getByRole('dialog', { name: ... }) 而非裸的 getByRole('dialog'),因为丢弃确认框可能与它短暂共存,裸角色定位器会触发 Playwright strict-mode 的"多元素命中"违规。这正是文档第 3 节"从 root 限定作用域、减少碰撞"理念在模态框场景下的延伸。
8. 约定速查表
| 维度 | 约定 | 检验方式 |
|---|---|---|
| 对象分层 | 根门面 InsomniaApp 组合 Page/Component;Page 继承 BasePage 获得共享组件 |
对照 insomnia-app.ts 与 base-page.ts |
| 拆分边界 | 路由工作流进 Page;可复用 UI 区域进 Component | WorkspaceListComponent、StatusbarComponent |
| 定位器 | 每类必须有 root getter;参数化用 xxxLocator(...);固定元素用 getter;尽量从 root 限定作用域 |
WorkspaceListComponent.workspaceLocator、NavigationSidebar.requestRow |
| 选择器 | 优先 getByTestId / getByRole / getByLabel,避免脆弱 CSS |
data-testid:statusbar、workspace-grid、project-node-* |
| 命名 | 动作动词开头;一个方法一个行为 | openPreferences、clickRequestOrFolder、selectCreateAction |
| 断言 | 业务断言留在测试文件;POM 只做交互生效的软校验 | expect.soft 仅见于 POM 内,测试文件持有结论断言 |
| 实例化 | 每测试经 insomnia fixture 获得一次门面实例 |
test.ts 的 insomnia fixture |
9. 小结
Insomnia smoke-test 的 POM 约定本质上是一套"选择器治理 + 稳定性工程"规范:分层对象让选择器知识单一来源化,root 限定作用域与 role/testid 定位让选择器对 UI 重构免疫,竞态容错与原生对话框 mock 把 Electron 桌面端特有的不稳定因素封装在 POM 内部,测试文件因此保持为纯业务叙事。对于需要在 Electron 或复杂单页应用上维护 E2E 测试的项目,这套 POM Conventions 及其配套实现(fixture 链、relaunch/launchClone、liveApps 清理)提供了可以直接对照迁移的完整范式;运行入口与调试方式(npm run test:smoke:dev、PWDEBUG=1、trace 回放)见 Smoke Test README。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00