# E2E Testing Guide 本目录包含 Cherry Studio 的端到端 (E2E) 测试,使用 Playwright 测试 Electron 应用。 ## 目录结构 ``` tests/e2e/ ├── README.md # 本文档 ├── global-setup.ts # 全局测试初始化 ├── global-teardown.ts # 全局测试清理 ├── fixtures/ │ └── electron.fixture.ts # Electron 应用启动 fixture ├── utils/ │ ├── wait-helpers.ts # 等待辅助函数 │ └── index.ts # 工具导出 ├── pages/ # Page Object Model │ ├── base.page.ts # 基础页面对象类 │ ├── sidebar.page.ts # 侧边栏导航 │ ├── home.page.ts # 首页/聊天页 │ ├── settings.page.ts # 设置页 │ ├── chat.page.ts # 聊天交互 │ └── index.ts # 页面对象导出 └── specs/ # 测试用例 ├── app-launch.spec.ts # 应用启动测试 ├── navigation.spec.ts # 页面导航测试 ├── settings/ # 设置相关测试 │ └── general.spec.ts └── conversation/ # 对话相关测试 └── basic-chat.spec.ts ``` --- ## 运行测试 ### 前置条件 1. 安装依赖:`yarn install` 2. 构建应用:`yarn build` ### 运行命令 ```bash # 运行所有 e2e 测试 yarn test:e2e # 带可视化窗口运行(可以看到测试过程) yarn test:e2e --headed # 运行特定测试文件 yarn playwright test tests/e2e/specs/app-launch.spec.ts # 运行匹配名称的测试 yarn playwright test -g "should launch" # 调试模式(会暂停并打开调试器) yarn playwright test --debug # 使用 Playwright UI 模式 yarn playwright test --ui # 查看测试报告 yarn playwright show-report ``` ### 常见问题 **Q: 测试时看不到窗口?** A: 默认是 headless 模式,使用 `--headed` 参数可看到窗口。 **Q: 测试失败,提示找不到元素?** A: 1. 确保已运行 `yarn build` 构建最新代码 2. 检查选择器是否正确,UI 可能已更新 **Q: 测试超时?** A: Electron 应用启动较慢,可在测试中增加超时时间: ```typescript test.setTimeout(60000) // 60秒 ``` --- ## AI 助手指南:创建新测试用例 以下内容供 AI 助手(如 Claude、GPT)在创建新测试用例时参考。 ### 基本原则 1. **使用 Page Object Model (POM)**:所有页面交互应通过 `pages/` 目录下的页面对象进行 2. **使用自定义 fixture**:从 `../fixtures/electron.fixture` 导入 `test` 和 `expect` 3. **等待策略**:使用 `utils/wait-helpers.ts` 中的等待函数,避免硬编码 `waitForTimeout` 4. **测试独立性**:每个测试应该独立运行,不依赖其他测试的状态 ### 创建新测试文件 ```typescript // tests/e2e/specs/[feature]/[feature].spec.ts import { test, expect } from '../../fixtures/electron.fixture' import { SomePageObject } from '../../pages/some.page' import { waitForAppReady } from '../../utils/wait-helpers' test.describe('Feature Name', () => { let pageObject: SomePageObject test.beforeEach(async ({ mainWindow }) => { await waitForAppReady(mainWindow) pageObject = new SomePageObject(mainWindow) }) test('should do something', async ({ mainWindow }) => { // 测试逻辑 }) }) ``` ### 创建新页面对象 ```typescript // tests/e2e/pages/[feature].page.ts import { Page, Locator } from '@playwright/test' import { BasePage } from './base.page' export class FeaturePage extends BasePage { // 定义页面元素定位器 readonly someButton: Locator readonly someInput: Locator constructor(page: Page) { super(page) // 使用多种选择器策略,提高稳定性 this.someButton = page.locator('[class*="SomeButton"], button:has-text("Some Text")') this.someInput = page.locator('input[placeholder*="placeholder"]') } // 页面操作方法 async doSomething(): Promise { await this.someButton.click() } // 状态检查方法 async isSomethingVisible(): Promise { return this.someButton.isVisible() } } ``` ### 选择器最佳实践 ```typescript // 优先级从高到低: // 1. data-testid(最稳定,但需要在源码中添加) page.locator('[data-testid="submit-button"]') // 2. 语义化角色 page.locator('button[role="submit"]') page.locator('[aria-label="Send message"]') // 3. 类名模糊匹配(适应 CSS Modules / styled-components) page.locator('[class*="SendButton"]') page.locator('[class*="send-button"]') // 4. 文本内容 page.locator('button:has-text("发送")') page.locator('text=Submit') // 5. 组合选择器(提高稳定性) page.locator('[class*="ChatInput"] textarea, [class*="InputBar"] textarea') // 避免使用: // - 精确类名(容易因构建变化而失效) // - 层级过深的选择器 // - 索引选择器(如 nth-child)除非必要 ``` ### 等待策略 ```typescript import { waitForAppReady, waitForNavigation, waitForModal } from '../../utils/wait-helpers' // 等待应用就绪 await waitForAppReady(mainWindow) // 等待导航完成(HashRouter) await waitForNavigation(mainWindow, '/settings') // 等待模态框出现 await waitForModal(mainWindow) // 等待元素可见 await page.locator('.some-element').waitFor({ state: 'visible', timeout: 10000 }) // 等待元素消失 await page.locator('.loading').waitFor({ state: 'hidden' }) // 避免使用固定等待时间 // BAD: await page.waitForTimeout(3000) // GOOD: await page.waitForSelector('.element', { state: 'visible' }) ``` ### 断言模式 ```typescript // 使用 Playwright 的自动重试断言 await expect(page.locator('.element')).toBeVisible() await expect(page.locator('.element')).toHaveText('expected text') await expect(page.locator('.element')).toHaveCount(3) // 检查 URL(HashRouter) await expect(page).toHaveURL(/.*#\/settings.*/) // 软断言(不会立即失败) await expect.soft(page.locator('.element')).toBeVisible() // 自定义超时 await expect(page.locator('.slow-element')).toBeVisible({ timeout: 30000 }) ``` ### 处理 Electron 特性 ```typescript // 访问 Electron 主进程 const bounds = await electronApp.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] return win?.getBounds() }) // 检查窗口状态 const isMaximized = await electronApp.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] return win?.isMaximized() }) // 调用 IPC(通过 preload 暴露的 API) const result = await mainWindow.evaluate(() => { return (window as any).api.someMethod() }) ``` ### 测试文件命名规范 ``` specs/ ├── [feature].spec.ts # 单文件测试 ├── [feature]/ │ ├── [sub-feature].spec.ts # 子功能测试 │ └── [another].spec.ts ``` 示例: - `app-launch.spec.ts` - 应用启动 - `navigation.spec.ts` - 页面导航 - `settings/general.spec.ts` - 通用设置 - `conversation/basic-chat.spec.ts` - 基础聊天 ### 添加新页面对象后的清单 1. 在 `pages/` 目录创建 `[feature].page.ts` 2. 继承 `BasePage` 类 3. 在 `pages/index.ts` 中导出 4. 在对应的 spec 文件中导入使用 ### 测试用例编写清单 - [ ] 使用自定义 fixture (`test`, `expect`) - [ ] 在 `beforeEach` 中调用 `waitForAppReady` - [ ] 使用 Page Object 进行页面交互 - [ ] 使用描述性的测试名称 - [ ] 添加适当的断言 - [ ] 处理可能的异步操作 - [ ] 考虑测试失败时的清理 ### 调试技巧 ```typescript // 截图调试 await mainWindow.screenshot({ path: 'debug.png' }) // 打印页面 HTML console.log(await mainWindow.content()) // 暂停测试进行调试 await mainWindow.pause() // 打印元素数量 console.log(await page.locator('.element').count()) ``` --- ## 配置文件 主要配置在项目根目录的 `playwright.config.ts`: - `testDir`: 测试目录 (`./tests/e2e/specs`) - `timeout`: 测试超时 (60秒) - `workers`: 并发数 (1,Electron 需要串行) - `retries`: 重试次数 (CI 环境下为 2) --- ## 相关文档 - [Playwright 官方文档](https://playwright.dev/docs/intro) - [Playwright Electron 测试](https://playwright.dev/docs/api/class-electron) - [Page Object Model](https://playwright.dev/docs/pom)