feat(test): e2e framework (#11494)
* feat(test): e2e framework Add Playwright-based e2e testing framework for Electron app with: - Custom fixtures for electronApp and mainWindow - Page Object Model (POM) pattern implementation - 15 example test cases covering app launch, navigation, settings, and chat - Comprehensive README for humans and AI assistants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(tests): update imports and improve code readability - Changed imports from 'import { Page, Locator }' to 'import type { Locator, Page }' for better type clarity across multiple page files. - Reformatted waitFor calls in ChatPage and HomePage for improved readability. - Updated index.ts to correct the export order of ChatPage and SidebarPage. - Minor adjustments in electron.fixture.ts and electron-app.ts for consistency in import statements. These changes enhance the maintainability and clarity of the test codebase. * chore: update linting configuration to include tests directory - Added 'tests/**' to the ignore patterns in .oxlintrc.json and eslint.config.mjs to ensure test files are not linted. - Minor adjustment in electron.fixture.ts to improve the fixture definition. These changes streamline the linting process and enhance code organization. * fix(test): select main window by title to fix flaky e2e tests on Mac On Mac, the app may create miniWindow for QuickAssistant alongside mainWindow. Using firstWindow() could randomly select the wrong window, causing test failures. Now we wait for the window with title "Cherry Studio" to ensure we get the main window. Also removed unused electron-app.ts utility file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"dist/**",
|
"dist/**",
|
||||||
"out/**",
|
"out/**",
|
||||||
"local/**",
|
"local/**",
|
||||||
|
"tests/**",
|
||||||
".yarn/**",
|
".yarn/**",
|
||||||
".gitignore",
|
".gitignore",
|
||||||
"scripts/cloudflare-worker.js",
|
"scripts/cloudflare-worker.js",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default defineConfig([
|
|||||||
'dist/**',
|
'dist/**',
|
||||||
'out/**',
|
'out/**',
|
||||||
'local/**',
|
'local/**',
|
||||||
|
'tests/**',
|
||||||
'.yarn/**',
|
'.yarn/**',
|
||||||
'.gitignore',
|
'.gitignore',
|
||||||
'scripts/cloudflare-worker.js',
|
'scripts/cloudflare-worker.js',
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
||||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||||
"@opeoginni/github-copilot-openai-compatible": "^0.1.21",
|
"@opeoginni/github-copilot-openai-compatible": "^0.1.21",
|
||||||
"@playwright/test": "^1.52.0",
|
"@playwright/test": "^1.55.1",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@reduxjs/toolkit": "^2.2.5",
|
"@reduxjs/toolkit": "^2.2.5",
|
||||||
"@shikijs/markdown-it": "^3.12.0",
|
"@shikijs/markdown-it": "^3.12.0",
|
||||||
@@ -321,7 +321,6 @@
|
|||||||
"p-queue": "^8.1.0",
|
"p-queue": "^8.1.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"playwright": "^1.55.1",
|
|
||||||
"proxy-agent": "^6.5.0",
|
"proxy-agent": "^6.5.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@@ -1,42 +1,64 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* Playwright configuration for Electron e2e testing.
|
||||||
|
* See https://playwright.dev/docs/test-configuration
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// Look for test files, relative to this configuration file.
|
// Look for test files in the specs directory
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e/specs',
|
||||||
/* Run tests in files in parallel */
|
|
||||||
fullyParallel: true,
|
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
/* Retry on CI only */
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
/* Opt out of parallel tests on CI. */
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
|
||||||
reporter: 'html',
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
||||||
use: {
|
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
|
||||||
// baseURL: 'http://localhost:3000',
|
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
// Global timeout for each test
|
||||||
trace: 'on-first-retry'
|
timeout: 60000,
|
||||||
|
|
||||||
|
// Assertion timeout
|
||||||
|
expect: {
|
||||||
|
timeout: 10000
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
// Electron apps should run tests sequentially to avoid conflicts
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
|
||||||
|
// Fail the build on CI if you accidentally left test.only in the source code
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
|
||||||
|
// Retry on CI only
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
||||||
|
// Reporter configuration
|
||||||
|
reporter: [['html', { outputFolder: 'playwright-report' }], ['list']],
|
||||||
|
|
||||||
|
// Global setup and teardown
|
||||||
|
globalSetup: './tests/e2e/global-setup.ts',
|
||||||
|
globalTeardown: './tests/e2e/global-teardown.ts',
|
||||||
|
|
||||||
|
// Output directory for test artifacts
|
||||||
|
outputDir: './test-results',
|
||||||
|
|
||||||
|
// Shared settings for all tests
|
||||||
|
use: {
|
||||||
|
// Collect trace when retrying the failed test
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
|
||||||
|
// Take screenshot only on failure
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
|
||||||
|
// Record video only on failure
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
|
||||||
|
// Action timeout
|
||||||
|
actionTimeout: 15000,
|
||||||
|
|
||||||
|
// Navigation timeout
|
||||||
|
navigationTimeout: 30000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Single project for Electron testing
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'electron',
|
||||||
use: { ...devices['Desktop Chrome'] }
|
testMatch: '**/*.spec.ts'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
|
||||||
// webServer: {
|
|
||||||
// command: 'npm run start',
|
|
||||||
// url: 'http://localhost:3000',
|
|
||||||
// reuseExistingServer: !process.env.CI,
|
|
||||||
// },
|
|
||||||
})
|
})
|
||||||
|
|||||||
310
tests/e2e/README.md
Normal file
310
tests/e2e/README.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# 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<void> {
|
||||||
|
await this.someButton.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态检查方法
|
||||||
|
async isSomethingVisible(): Promise<boolean> {
|
||||||
|
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)
|
||||||
53
tests/e2e/fixtures/electron.fixture.ts
Normal file
53
tests/e2e/fixtures/electron.fixture.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ElectronApplication, Page } from '@playwright/test'
|
||||||
|
import { _electron as electron, test as base } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom fixtures for Electron e2e testing.
|
||||||
|
* Provides electronApp and mainWindow to all tests.
|
||||||
|
*/
|
||||||
|
export type ElectronFixtures = {
|
||||||
|
electronApp: ElectronApplication
|
||||||
|
mainWindow: Page
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<ElectronFixtures>({
|
||||||
|
electronApp: async ({}, use) => {
|
||||||
|
// Launch Electron app from project root
|
||||||
|
// The args ['.'] tells Electron to load the app from current directory
|
||||||
|
const electronApp = await electron.launch({
|
||||||
|
args: ['.'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'development'
|
||||||
|
},
|
||||||
|
timeout: 60000
|
||||||
|
})
|
||||||
|
|
||||||
|
await use(electronApp)
|
||||||
|
|
||||||
|
// Cleanup: close the app after test
|
||||||
|
await electronApp.close()
|
||||||
|
},
|
||||||
|
|
||||||
|
mainWindow: async ({ electronApp }, use) => {
|
||||||
|
// Wait for the main window (title: "Cherry Studio", not "Quick Assistant")
|
||||||
|
// On Mac, the app may create miniWindow for QuickAssistant with different title
|
||||||
|
const mainWindow = await electronApp.waitForEvent('window', {
|
||||||
|
predicate: async (window) => {
|
||||||
|
const title = await window.title()
|
||||||
|
return title === 'Cherry Studio'
|
||||||
|
},
|
||||||
|
timeout: 60000
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for React app to mount
|
||||||
|
await mainWindow.waitForSelector('#root', { state: 'attached', timeout: 60000 })
|
||||||
|
|
||||||
|
// Wait for initial content to load
|
||||||
|
await mainWindow.waitForLoadState('domcontentloaded')
|
||||||
|
|
||||||
|
await use(mainWindow)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test'
|
||||||
25
tests/e2e/global-setup.ts
Normal file
25
tests/e2e/global-setup.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global setup for Playwright e2e tests.
|
||||||
|
* This runs once before all tests.
|
||||||
|
*/
|
||||||
|
async function globalSetup() {
|
||||||
|
console.log('Running global setup...')
|
||||||
|
|
||||||
|
// Create test results directories
|
||||||
|
const resultsDir = path.join(process.cwd(), 'test-results')
|
||||||
|
const screenshotsDir = path.join(resultsDir, 'screenshots')
|
||||||
|
|
||||||
|
if (!fs.existsSync(screenshotsDir)) {
|
||||||
|
fs.mkdirSync(screenshotsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment variables for testing
|
||||||
|
process.env.NODE_ENV = 'test'
|
||||||
|
|
||||||
|
console.log('Global setup complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalSetup
|
||||||
16
tests/e2e/global-teardown.ts
Normal file
16
tests/e2e/global-teardown.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Global teardown for Playwright e2e tests.
|
||||||
|
* This runs once after all tests complete.
|
||||||
|
*/
|
||||||
|
async function globalTeardown() {
|
||||||
|
console.log('Running global teardown...')
|
||||||
|
|
||||||
|
// Cleanup tasks can be added here:
|
||||||
|
// - Kill orphaned Electron processes
|
||||||
|
// - Clean up temporary test data
|
||||||
|
// - Reset test databases
|
||||||
|
|
||||||
|
console.log('Global teardown complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalTeardown
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { _electron as electron, expect, test } from '@playwright/test'
|
|
||||||
|
|
||||||
let electronApp: any
|
|
||||||
let window: any
|
|
||||||
|
|
||||||
test.describe('App Launch', () => {
|
|
||||||
test('should launch and close the main application', async () => {
|
|
||||||
electronApp = await electron.launch({ args: ['.'] })
|
|
||||||
window = await electronApp.firstWindow()
|
|
||||||
expect(window).toBeDefined()
|
|
||||||
await electronApp.close()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
110
tests/e2e/pages/base.page.ts
Normal file
110
tests/e2e/pages/base.page.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Page Object class.
|
||||||
|
* All page objects should extend this class.
|
||||||
|
*/
|
||||||
|
export abstract class BasePage {
|
||||||
|
constructor(protected page: Page) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a path using HashRouter.
|
||||||
|
* The app uses HashRouter, so we need to change window.location.hash.
|
||||||
|
*/
|
||||||
|
async navigateTo(routePath: string): Promise<void> {
|
||||||
|
await this.page.evaluate((p) => {
|
||||||
|
window.location.hash = p
|
||||||
|
}, routePath)
|
||||||
|
await this.page.waitForLoadState('domcontentloaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for an element to be visible.
|
||||||
|
*/
|
||||||
|
async waitForElement(selector: string, timeout: number = 10000): Promise<Locator> {
|
||||||
|
const locator = this.page.locator(selector)
|
||||||
|
await locator.waitFor({ state: 'visible', timeout })
|
||||||
|
return locator
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for an element to be hidden.
|
||||||
|
*/
|
||||||
|
async waitForElementHidden(selector: string, timeout: number = 10000): Promise<void> {
|
||||||
|
const locator = this.page.locator(selector)
|
||||||
|
await locator.waitFor({ state: 'hidden', timeout })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a screenshot for debugging.
|
||||||
|
*/
|
||||||
|
async takeScreenshot(name: string): Promise<void> {
|
||||||
|
const screenshotsDir = path.join(process.cwd(), 'test-results', 'screenshots')
|
||||||
|
if (!fs.existsSync(screenshotsDir)) {
|
||||||
|
fs.mkdirSync(screenshotsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.page.screenshot({
|
||||||
|
path: path.join(screenshotsDir, `${name}.png`),
|
||||||
|
fullPage: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current route from the hash.
|
||||||
|
*/
|
||||||
|
async getCurrentRoute(): Promise<string> {
|
||||||
|
const url = this.page.url()
|
||||||
|
const hash = new URL(url).hash
|
||||||
|
return hash.replace('#', '') || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click an element with retry.
|
||||||
|
*/
|
||||||
|
async clickWithRetry(selector: string, maxRetries: number = 3): Promise<void> {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
await this.page.click(selector, { timeout: 5000 })
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
if (i === maxRetries - 1) throw error
|
||||||
|
await this.page.waitForTimeout(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill an input field.
|
||||||
|
*/
|
||||||
|
async fillInput(selector: string, value: string): Promise<void> {
|
||||||
|
const input = this.page.locator(selector)
|
||||||
|
await input.fill(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get text content of an element.
|
||||||
|
*/
|
||||||
|
async getTextContent(selector: string): Promise<string | null> {
|
||||||
|
const locator = this.page.locator(selector)
|
||||||
|
return locator.textContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an element is visible.
|
||||||
|
*/
|
||||||
|
async isElementVisible(selector: string): Promise<boolean> {
|
||||||
|
const locator = this.page.locator(selector)
|
||||||
|
return locator.isVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count elements matching a selector.
|
||||||
|
*/
|
||||||
|
async countElements(selector: string): Promise<number> {
|
||||||
|
const locator = this.page.locator(selector)
|
||||||
|
return locator.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
140
tests/e2e/pages/chat.page.ts
Normal file
140
tests/e2e/pages/chat.page.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { BasePage } from './base.page'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Object for the Chat/Conversation interface.
|
||||||
|
* Handles message input, sending, and conversation management.
|
||||||
|
*/
|
||||||
|
export class ChatPage extends BasePage {
|
||||||
|
readonly chatContainer: Locator
|
||||||
|
readonly inputArea: Locator
|
||||||
|
readonly sendButton: Locator
|
||||||
|
readonly messageList: Locator
|
||||||
|
readonly userMessages: Locator
|
||||||
|
readonly assistantMessages: Locator
|
||||||
|
readonly newTopicButton: Locator
|
||||||
|
readonly topicList: Locator
|
||||||
|
readonly stopButton: Locator
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
super(page)
|
||||||
|
this.chatContainer = page.locator('#chat, [class*="Chat"]')
|
||||||
|
this.inputArea = page.locator(
|
||||||
|
'[class*="Inputbar"] textarea, [class*="InputBar"] textarea, [contenteditable="true"]'
|
||||||
|
)
|
||||||
|
this.sendButton = page.locator(
|
||||||
|
'[class*="SendMessageButton"], [class*="send-button"], button[aria-label*="send"], button[title*="send"]'
|
||||||
|
)
|
||||||
|
this.messageList = page.locator('#messages, [class*="Messages"], [class*="MessageList"]')
|
||||||
|
this.userMessages = page.locator('[class*="UserMessage"], [class*="user-message"]')
|
||||||
|
this.assistantMessages = page.locator('[class*="AssistantMessage"], [class*="assistant-message"]')
|
||||||
|
this.newTopicButton = page.locator('[class*="NewTopicButton"], [class*="new-topic"]')
|
||||||
|
this.topicList = page.locator('[class*="TopicList"], [class*="topic-list"]')
|
||||||
|
this.stopButton = page.locator('[class*="StopButton"], [class*="stop-button"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to chat/home page.
|
||||||
|
*/
|
||||||
|
async goto(): Promise<void> {
|
||||||
|
await this.navigateTo('/')
|
||||||
|
await this.chatContainer
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: 'visible', timeout: 15000 })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if chat is visible.
|
||||||
|
*/
|
||||||
|
async isChatVisible(): Promise<boolean> {
|
||||||
|
return this.chatContainer.first().isVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type a message in the input area.
|
||||||
|
*/
|
||||||
|
async typeMessage(message: string): Promise<void> {
|
||||||
|
await this.inputArea.first().fill(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the input area.
|
||||||
|
*/
|
||||||
|
async clearInput(): Promise<void> {
|
||||||
|
await this.inputArea.first().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the send button.
|
||||||
|
*/
|
||||||
|
async clickSend(): Promise<void> {
|
||||||
|
await this.sendButton.first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type and send a message.
|
||||||
|
*/
|
||||||
|
async sendMessage(message: string): Promise<void> {
|
||||||
|
await this.typeMessage(message)
|
||||||
|
await this.clickSend()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current input value.
|
||||||
|
*/
|
||||||
|
async getInputValue(): Promise<string> {
|
||||||
|
return (await this.inputArea.first().inputValue()) || (await this.inputArea.first().textContent()) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of user messages.
|
||||||
|
*/
|
||||||
|
async getUserMessageCount(): Promise<number> {
|
||||||
|
return this.userMessages.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of assistant messages.
|
||||||
|
*/
|
||||||
|
async getAssistantMessageCount(): Promise<number> {
|
||||||
|
return this.assistantMessages.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if send button is enabled.
|
||||||
|
*/
|
||||||
|
async isSendButtonEnabled(): Promise<boolean> {
|
||||||
|
const isDisabled = await this.sendButton.first().isDisabled()
|
||||||
|
return !isDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new topic/conversation.
|
||||||
|
*/
|
||||||
|
async createNewTopic(): Promise<void> {
|
||||||
|
await this.newTopicButton.first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if stop button is visible (indicates ongoing generation).
|
||||||
|
*/
|
||||||
|
async isGenerating(): Promise<boolean> {
|
||||||
|
return this.stopButton.first().isVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click stop button to stop generation.
|
||||||
|
*/
|
||||||
|
async stopGeneration(): Promise<void> {
|
||||||
|
await this.stopButton.first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for generation to complete.
|
||||||
|
*/
|
||||||
|
async waitForGenerationComplete(timeout: number = 60000): Promise<void> {
|
||||||
|
await this.stopButton.first().waitFor({ state: 'hidden', timeout })
|
||||||
|
}
|
||||||
|
}
|
||||||
110
tests/e2e/pages/home.page.ts
Normal file
110
tests/e2e/pages/home.page.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { BasePage } from './base.page'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Object for the Home/Chat page.
|
||||||
|
* This is the main page where users interact with AI assistants.
|
||||||
|
*/
|
||||||
|
export class HomePage extends BasePage {
|
||||||
|
readonly homePage: Locator
|
||||||
|
readonly chatContainer: Locator
|
||||||
|
readonly inputBar: Locator
|
||||||
|
readonly messagesList: Locator
|
||||||
|
readonly sendButton: Locator
|
||||||
|
readonly newTopicButton: Locator
|
||||||
|
readonly assistantTabs: Locator
|
||||||
|
readonly topicList: Locator
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
super(page)
|
||||||
|
this.homePage = page.locator('#home-page, [class*="HomePage"], [class*="Home"]')
|
||||||
|
this.chatContainer = page.locator('#chat, [class*="Chat"]')
|
||||||
|
this.inputBar = page.locator('[class*="Inputbar"], [class*="InputBar"], [class*="input-bar"]')
|
||||||
|
this.messagesList = page.locator('#messages, [class*="Messages"], [class*="MessageList"]')
|
||||||
|
this.sendButton = page.locator('[class*="SendMessageButton"], [class*="send-button"], button[type="submit"]')
|
||||||
|
this.newTopicButton = page.locator('[class*="NewTopicButton"], [class*="new-topic"]')
|
||||||
|
this.assistantTabs = page.locator('[class*="HomeTabs"], [class*="AssistantTabs"]')
|
||||||
|
this.topicList = page.locator('[class*="TopicList"], [class*="topic-list"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the home page.
|
||||||
|
*/
|
||||||
|
async goto(): Promise<void> {
|
||||||
|
await this.navigateTo('/')
|
||||||
|
await this.homePage
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: 'visible', timeout: 15000 })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the home page is loaded.
|
||||||
|
*/
|
||||||
|
async isLoaded(): Promise<boolean> {
|
||||||
|
return this.homePage.first().isVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type a message in the input area.
|
||||||
|
*/
|
||||||
|
async typeMessage(message: string): Promise<void> {
|
||||||
|
const input = this.page.locator(
|
||||||
|
'[class*="Inputbar"] textarea, [class*="Inputbar"] [contenteditable], [class*="InputBar"] textarea'
|
||||||
|
)
|
||||||
|
await input.first().fill(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the send button to send a message.
|
||||||
|
*/
|
||||||
|
async sendMessage(): Promise<void> {
|
||||||
|
await this.sendButton.first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type and send a message.
|
||||||
|
*/
|
||||||
|
async sendChatMessage(message: string): Promise<void> {
|
||||||
|
await this.typeMessage(message)
|
||||||
|
await this.sendMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of messages in the chat.
|
||||||
|
*/
|
||||||
|
async getMessageCount(): Promise<number> {
|
||||||
|
const messages = this.page.locator('[class*="Message"]:not([class*="Messages"]):not([class*="MessageList"])')
|
||||||
|
return messages.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new topic/conversation.
|
||||||
|
*/
|
||||||
|
async createNewTopic(): Promise<void> {
|
||||||
|
await this.newTopicButton.first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the chat interface is visible.
|
||||||
|
*/
|
||||||
|
async isChatVisible(): Promise<boolean> {
|
||||||
|
return this.chatContainer.first().isVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the input bar is visible.
|
||||||
|
*/
|
||||||
|
async isInputBarVisible(): Promise<boolean> {
|
||||||
|
return this.inputBar.first().isVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the placeholder text of the input field.
|
||||||
|
*/
|
||||||
|
async getInputPlaceholder(): Promise<string | null> {
|
||||||
|
const input = this.page.locator('[class*="Inputbar"] textarea, [class*="InputBar"] textarea')
|
||||||
|
return input.first().getAttribute('placeholder')
|
||||||
|
}
|
||||||
|
}
|
||||||
8
tests/e2e/pages/index.ts
Normal file
8
tests/e2e/pages/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Export all page objects for easy importing.
|
||||||
|
*/
|
||||||
|
export { BasePage } from './base.page'
|
||||||
|
export { ChatPage } from './chat.page'
|
||||||
|
export { HomePage } from './home.page'
|
||||||
|
export { SettingsPage } from './settings.page'
|
||||||
|
export { SidebarPage } from './sidebar.page'
|
||||||
159
tests/e2e/pages/settings.page.ts
Normal file
159
tests/e2e/pages/settings.page.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { BasePage } from './base.page'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Object for the Settings page.
|
||||||
|
* Handles navigation and interaction with various settings sections.
|
||||||
|
*/
|
||||||
|
export class SettingsPage extends BasePage {
|
||||||
|
readonly settingsContainer: Locator
|
||||||
|
readonly providerMenuItem: Locator
|
||||||
|
readonly modelMenuItem: Locator
|
||||||
|
readonly generalMenuItem: Locator
|
||||||
|
readonly displayMenuItem: Locator
|
||||||
|
readonly dataMenuItem: Locator
|
||||||
|
readonly mcpMenuItem: Locator
|
||||||
|
readonly memoryMenuItem: Locator
|
||||||
|
readonly aboutMenuItem: Locator
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
super(page)
|
||||||
|
this.settingsContainer = page.locator('[id="content-container"], [class*="Settings"]')
|
||||||
|
this.providerMenuItem = page.locator('a[href*="/settings/provider"]')
|
||||||
|
this.modelMenuItem = page.locator('a[href*="/settings/model"]')
|
||||||
|
this.generalMenuItem = page.locator('a[href*="/settings/general"]')
|
||||||
|
this.displayMenuItem = page.locator('a[href*="/settings/display"]')
|
||||||
|
this.dataMenuItem = page.locator('a[href*="/settings/data"]')
|
||||||
|
this.mcpMenuItem = page.locator('a[href*="/settings/mcp"]')
|
||||||
|
this.memoryMenuItem = page.locator('a[href*="/settings/memory"]')
|
||||||
|
this.aboutMenuItem = page.locator('a[href*="/settings/about"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to settings page (provider by default).
|
||||||
|
*/
|
||||||
|
async goto(): Promise<void> {
|
||||||
|
await this.navigateTo('/settings/provider')
|
||||||
|
await this.waitForElement('[id="content-container"], [class*="Settings"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if settings page is loaded.
|
||||||
|
*/
|
||||||
|
async isLoaded(): Promise<boolean> {
|
||||||
|
return this.settingsContainer.first().isVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Provider settings.
|
||||||
|
*/
|
||||||
|
async goToProvider(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.providerMenuItem.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/provider')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/provider**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Model settings.
|
||||||
|
*/
|
||||||
|
async goToModel(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.modelMenuItem.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/model')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/model**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to General settings.
|
||||||
|
*/
|
||||||
|
async goToGeneral(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.generalMenuItem.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/general')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/general**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Display settings.
|
||||||
|
*/
|
||||||
|
async goToDisplay(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.displayMenuItem.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/display')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/display**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Data settings.
|
||||||
|
*/
|
||||||
|
async goToData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dataMenuItem.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/data')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/data**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to MCP settings.
|
||||||
|
*/
|
||||||
|
async goToMCP(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.mcpMenuItem.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/mcp')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/mcp**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Memory settings.
|
||||||
|
*/
|
||||||
|
async goToMemory(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.memoryMenuItem.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/memory')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/memory**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to About page.
|
||||||
|
*/
|
||||||
|
async goToAbout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.aboutMenuItem.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/about')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/about**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a switch setting by its label.
|
||||||
|
*/
|
||||||
|
async toggleSwitch(label: string): Promise<void> {
|
||||||
|
const switchElement = this.page.locator(`text=${label}`).locator('..').locator('button[role="switch"], .ant-switch')
|
||||||
|
await switchElement.first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a menu item is active/selected.
|
||||||
|
*/
|
||||||
|
async isMenuItemActive(menuItem: Locator): Promise<boolean> {
|
||||||
|
const className = await menuItem.getAttribute('class')
|
||||||
|
return className?.includes('active') || className?.includes('selected') || false
|
||||||
|
}
|
||||||
|
}
|
||||||
122
tests/e2e/pages/sidebar.page.ts
Normal file
122
tests/e2e/pages/sidebar.page.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { BasePage } from './base.page'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Object for the Sidebar/Navigation component.
|
||||||
|
* Handles navigation between different sections of the app.
|
||||||
|
*/
|
||||||
|
export class SidebarPage extends BasePage {
|
||||||
|
readonly sidebar: Locator
|
||||||
|
readonly homeLink: Locator
|
||||||
|
readonly storeLink: Locator
|
||||||
|
readonly knowledgeLink: Locator
|
||||||
|
readonly filesLink: Locator
|
||||||
|
readonly settingsLink: Locator
|
||||||
|
readonly appsLink: Locator
|
||||||
|
readonly translateLink: Locator
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
super(page)
|
||||||
|
this.sidebar = page.locator('[class*="Sidebar"], nav, aside')
|
||||||
|
this.homeLink = page.locator('a[href="#/"], a[href="#!/"]').first()
|
||||||
|
this.storeLink = page.locator('a[href*="/store"]')
|
||||||
|
this.knowledgeLink = page.locator('a[href*="/knowledge"]')
|
||||||
|
this.filesLink = page.locator('a[href*="/files"]')
|
||||||
|
this.settingsLink = page.locator('a[href*="/settings"]')
|
||||||
|
this.appsLink = page.locator('a[href*="/apps"]')
|
||||||
|
this.translateLink = page.locator('a[href*="/translate"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Home page.
|
||||||
|
*/
|
||||||
|
async goToHome(): Promise<void> {
|
||||||
|
// Try clicking the home link, or navigate directly
|
||||||
|
try {
|
||||||
|
await this.homeLink.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL(/.*#\/$|.*#$|.*#\/home.*/, { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Knowledge page.
|
||||||
|
*/
|
||||||
|
async goToKnowledge(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.knowledgeLink.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/knowledge')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/knowledge**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Settings page.
|
||||||
|
*/
|
||||||
|
async goToSettings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.settingsLink.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/settings/provider')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/settings/**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Files page.
|
||||||
|
*/
|
||||||
|
async goToFiles(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.filesLink.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/files')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/files**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Apps page.
|
||||||
|
*/
|
||||||
|
async goToApps(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.appsLink.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/apps')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/apps**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Store page.
|
||||||
|
*/
|
||||||
|
async goToStore(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.storeLink.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/store')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/store**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to Translate page.
|
||||||
|
*/
|
||||||
|
async goToTranslate(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.translateLink.click({ timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
await this.navigateTo('/translate')
|
||||||
|
}
|
||||||
|
await this.page.waitForURL('**/#/translate**', { timeout: 10000 }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if sidebar is visible.
|
||||||
|
*/
|
||||||
|
async isVisible(): Promise<boolean> {
|
||||||
|
return this.sidebar.first().isVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
49
tests/e2e/specs/app-launch.spec.ts
Normal file
49
tests/e2e/specs/app-launch.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test } from '../fixtures/electron.fixture'
|
||||||
|
import { waitForAppReady } from '../utils/wait-helpers'
|
||||||
|
|
||||||
|
test.describe('App Launch', () => {
|
||||||
|
test('should launch the application successfully', async ({ mainWindow }) => {
|
||||||
|
await waitForAppReady(mainWindow)
|
||||||
|
expect(mainWindow).toBeDefined()
|
||||||
|
|
||||||
|
const title = await mainWindow.title()
|
||||||
|
expect(title).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should display the main content', async ({ mainWindow }) => {
|
||||||
|
await waitForAppReady(mainWindow)
|
||||||
|
|
||||||
|
// Check for main app content
|
||||||
|
const hasContent = await mainWindow.evaluate(() => {
|
||||||
|
const root = document.querySelector('#root')
|
||||||
|
return root !== null && root.innerHTML.length > 100
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(hasContent).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should have React root mounted', async ({ mainWindow }) => {
|
||||||
|
await waitForAppReady(mainWindow)
|
||||||
|
|
||||||
|
const hasReactRoot = await mainWindow.evaluate(() => {
|
||||||
|
const root = document.querySelector('#root')
|
||||||
|
return root !== null && root.children.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(hasReactRoot).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should have window with reasonable size', async ({ electronApp, mainWindow }) => {
|
||||||
|
await waitForAppReady(mainWindow)
|
||||||
|
|
||||||
|
const bounds = await electronApp.evaluate(({ BrowserWindow }) => {
|
||||||
|
const win = BrowserWindow.getAllWindows()[0]
|
||||||
|
return win?.getBounds()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bounds).toBeDefined()
|
||||||
|
// Window should have some reasonable size (may vary based on saved state)
|
||||||
|
expect(bounds!.width).toBeGreaterThan(400)
|
||||||
|
expect(bounds!.height).toBeGreaterThan(300)
|
||||||
|
})
|
||||||
|
})
|
||||||
35
tests/e2e/specs/conversation/basic-chat.spec.ts
Normal file
35
tests/e2e/specs/conversation/basic-chat.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { expect, test } from '../../fixtures/electron.fixture'
|
||||||
|
import { waitForAppReady } from '../../utils/wait-helpers'
|
||||||
|
|
||||||
|
test.describe('Basic Chat', () => {
|
||||||
|
test.beforeEach(async ({ mainWindow }) => {
|
||||||
|
await waitForAppReady(mainWindow)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should display main content on home page', async ({ mainWindow }) => {
|
||||||
|
// Home page is the default, just verify content exists
|
||||||
|
const hasContent = await mainWindow.evaluate(() => {
|
||||||
|
const root = document.querySelector('#root')
|
||||||
|
return root !== null && root.innerHTML.length > 100
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(hasContent).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should have input area for chat', async ({ mainWindow }) => {
|
||||||
|
// Look for textarea or input elements that could be chat input
|
||||||
|
const inputElements = mainWindow.locator('textarea, [contenteditable="true"], input[type="text"]')
|
||||||
|
const count = await inputElements.count()
|
||||||
|
|
||||||
|
// There should be at least one input element
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should have interactive elements', async ({ mainWindow }) => {
|
||||||
|
// Check for buttons or clickable elements
|
||||||
|
const buttons = mainWindow.locator('button')
|
||||||
|
const count = await buttons.count()
|
||||||
|
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
46
tests/e2e/specs/navigation.spec.ts
Normal file
46
tests/e2e/specs/navigation.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from '../fixtures/electron.fixture'
|
||||||
|
import { SidebarPage } from '../pages/sidebar.page'
|
||||||
|
import { waitForAppReady } from '../utils/wait-helpers'
|
||||||
|
|
||||||
|
test.describe('Navigation', () => {
|
||||||
|
let sidebarPage: SidebarPage
|
||||||
|
|
||||||
|
test.beforeEach(async ({ mainWindow }) => {
|
||||||
|
await waitForAppReady(mainWindow)
|
||||||
|
sidebarPage = new SidebarPage(mainWindow)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should navigate to Settings page', async ({ mainWindow }) => {
|
||||||
|
await sidebarPage.goToSettings()
|
||||||
|
|
||||||
|
// Wait a bit for navigation to complete
|
||||||
|
await mainWindow.waitForTimeout(1000)
|
||||||
|
|
||||||
|
const currentUrl = mainWindow.url()
|
||||||
|
expect(currentUrl).toContain('/settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should navigate to Files page', async ({ mainWindow }) => {
|
||||||
|
await sidebarPage.goToFiles()
|
||||||
|
|
||||||
|
await mainWindow.waitForTimeout(1000)
|
||||||
|
|
||||||
|
const currentUrl = mainWindow.url()
|
||||||
|
expect(currentUrl).toContain('/files')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should navigate back to Home', async ({ mainWindow }) => {
|
||||||
|
// First go to settings
|
||||||
|
await sidebarPage.goToSettings()
|
||||||
|
await mainWindow.waitForTimeout(1000)
|
||||||
|
|
||||||
|
// Then go back to home
|
||||||
|
await sidebarPage.goToHome()
|
||||||
|
await mainWindow.waitForTimeout(1000)
|
||||||
|
|
||||||
|
// Verify we're on home page
|
||||||
|
const currentUrl = mainWindow.url()
|
||||||
|
// Home page URL should be either / or empty hash
|
||||||
|
expect(currentUrl).toMatch(/#\/?$|#$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
55
tests/e2e/specs/settings/general.spec.ts
Normal file
55
tests/e2e/specs/settings/general.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from '../../fixtures/electron.fixture'
|
||||||
|
import { SettingsPage } from '../../pages/settings.page'
|
||||||
|
import { SidebarPage } from '../../pages/sidebar.page'
|
||||||
|
import { waitForAppReady } from '../../utils/wait-helpers'
|
||||||
|
|
||||||
|
test.describe('Settings Page', () => {
|
||||||
|
let settingsPage: SettingsPage
|
||||||
|
let sidebarPage: SidebarPage
|
||||||
|
|
||||||
|
test.beforeEach(async ({ mainWindow }) => {
|
||||||
|
await waitForAppReady(mainWindow)
|
||||||
|
sidebarPage = new SidebarPage(mainWindow)
|
||||||
|
settingsPage = new SettingsPage(mainWindow)
|
||||||
|
|
||||||
|
// Navigate to settings
|
||||||
|
await sidebarPage.goToSettings()
|
||||||
|
await mainWindow.waitForTimeout(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should display settings page', async ({ mainWindow }) => {
|
||||||
|
const currentUrl = mainWindow.url()
|
||||||
|
expect(currentUrl).toContain('/settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should have settings menu items', async ({ mainWindow }) => {
|
||||||
|
// Check for settings menu items by looking for links
|
||||||
|
const menuItems = mainWindow.locator('a[href*="/settings/"]')
|
||||||
|
const count = await menuItems.count()
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should navigate to General settings', async ({ mainWindow }) => {
|
||||||
|
await settingsPage.goToGeneral()
|
||||||
|
await mainWindow.waitForTimeout(500)
|
||||||
|
|
||||||
|
const currentUrl = mainWindow.url()
|
||||||
|
expect(currentUrl).toContain('/settings/general')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should navigate to Display settings', async ({ mainWindow }) => {
|
||||||
|
await settingsPage.goToDisplay()
|
||||||
|
await mainWindow.waitForTimeout(500)
|
||||||
|
|
||||||
|
const currentUrl = mainWindow.url()
|
||||||
|
expect(currentUrl).toContain('/settings/display')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should navigate to About page', async ({ mainWindow }) => {
|
||||||
|
await settingsPage.goToAbout()
|
||||||
|
await mainWindow.waitForTimeout(500)
|
||||||
|
|
||||||
|
const currentUrl = mainWindow.url()
|
||||||
|
expect(currentUrl).toContain('/settings/about')
|
||||||
|
})
|
||||||
|
})
|
||||||
4
tests/e2e/utils/index.ts
Normal file
4
tests/e2e/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Export all utilities for easy importing.
|
||||||
|
*/
|
||||||
|
export * from './wait-helpers'
|
||||||
103
tests/e2e/utils/wait-helpers.ts
Normal file
103
tests/e2e/utils/wait-helpers.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the application to be fully ready.
|
||||||
|
* The app uses PersistGate which may delay initial render.
|
||||||
|
* Layout can be either Sidebar-based or TabsContainer-based depending on settings.
|
||||||
|
*/
|
||||||
|
export async function waitForAppReady(page: Page, timeout: number = 60000): Promise<void> {
|
||||||
|
// First, wait for React root to be attached
|
||||||
|
await page.waitForSelector('#root', { state: 'attached', timeout })
|
||||||
|
|
||||||
|
// Wait for main app content to render
|
||||||
|
// The app may show either:
|
||||||
|
// 1. Sidebar layout (navbarPosition === 'left')
|
||||||
|
// 2. TabsContainer layout (default)
|
||||||
|
// 3. Home page content
|
||||||
|
await page.waitForSelector(
|
||||||
|
[
|
||||||
|
'#home-page', // Home page container
|
||||||
|
'[class*="Sidebar"]', // Sidebar component
|
||||||
|
'[class*="TabsContainer"]', // Tabs container
|
||||||
|
'[class*="home-navbar"]', // Home navbar
|
||||||
|
'[class*="Container"]' // Generic container from styled-components
|
||||||
|
].join(', '),
|
||||||
|
{
|
||||||
|
state: 'visible',
|
||||||
|
timeout
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Additional wait for React to fully hydrate
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for navigation to a specific path.
|
||||||
|
* The app uses HashRouter, so paths are prefixed with #.
|
||||||
|
*/
|
||||||
|
export async function waitForNavigation(page: Page, path: string, timeout: number = 15000): Promise<void> {
|
||||||
|
await page.waitForURL(`**/#${path}**`, { timeout })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the chat interface to be ready.
|
||||||
|
*/
|
||||||
|
export async function waitForChatReady(page: Page, timeout: number = 30000): Promise<void> {
|
||||||
|
await page.waitForSelector(
|
||||||
|
['#home-page', '[class*="Chat"]', '[class*="Inputbar"]', '[class*="home-tabs"]'].join(', '),
|
||||||
|
{ state: 'visible', timeout }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the settings page to load.
|
||||||
|
*/
|
||||||
|
export async function waitForSettingsLoad(page: Page, timeout: number = 30000): Promise<void> {
|
||||||
|
await page.waitForSelector(['[class*="SettingsPage"]', '[class*="Settings"]', 'a[href*="/settings/"]'].join(', '), {
|
||||||
|
state: 'visible',
|
||||||
|
timeout
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a modal/dialog to appear.
|
||||||
|
*/
|
||||||
|
export async function waitForModal(page: Page, timeout: number = 10000): Promise<void> {
|
||||||
|
await page.waitForSelector('.ant-modal, [role="dialog"], .ant-drawer', { state: 'visible', timeout })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a modal/dialog to close.
|
||||||
|
*/
|
||||||
|
export async function waitForModalClose(page: Page, timeout: number = 10000): Promise<void> {
|
||||||
|
await page.waitForSelector('.ant-modal, [role="dialog"], .ant-drawer', { state: 'hidden', timeout })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for loading state to complete.
|
||||||
|
*/
|
||||||
|
export async function waitForLoadingComplete(page: Page, timeout: number = 30000): Promise<void> {
|
||||||
|
const spinner = page.locator('.ant-spin, [class*="Loading"], [class*="Spinner"]')
|
||||||
|
if ((await spinner.count()) > 0) {
|
||||||
|
await spinner.first().waitFor({ state: 'hidden', timeout })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a notification/toast to appear.
|
||||||
|
*/
|
||||||
|
export async function waitForNotification(page: Page, timeout: number = 10000): Promise<void> {
|
||||||
|
await page.waitForSelector('.ant-notification, .ant-message, [class*="Notification"]', {
|
||||||
|
state: 'visible',
|
||||||
|
timeout
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for a specified duration.
|
||||||
|
* Use sparingly - prefer explicit waits when possible.
|
||||||
|
*/
|
||||||
|
export async function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
55
yarn.lock
55
yarn.lock
@@ -5437,14 +5437,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@playwright/test@npm:^1.52.0":
|
"@playwright/test@npm:^1.55.1":
|
||||||
version: 1.52.0
|
version: 1.57.0
|
||||||
resolution: "@playwright/test@npm:1.52.0"
|
resolution: "@playwright/test@npm:1.57.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: "npm:1.52.0"
|
playwright: "npm:1.57.0"
|
||||||
bin:
|
bin:
|
||||||
playwright: cli.js
|
playwright: cli.js
|
||||||
checksum: 10c0/1c428b421593eb4f79b7c99783a389c3ab3526c9051ec772749f4fca61414dfa9f2344eba846faac5f238084aa96c836364a91d81d3034ac54924f239a93e247
|
checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -10059,7 +10059,7 @@ __metadata:
|
|||||||
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
|
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
|
||||||
"@opeoginni/github-copilot-openai-compatible": "npm:^0.1.21"
|
"@opeoginni/github-copilot-openai-compatible": "npm:^0.1.21"
|
||||||
"@paymoapp/electron-shutdown-handler": "npm:^1.1.2"
|
"@paymoapp/electron-shutdown-handler": "npm:^1.1.2"
|
||||||
"@playwright/test": "npm:^1.52.0"
|
"@playwright/test": "npm:^1.55.1"
|
||||||
"@radix-ui/react-context-menu": "npm:^2.2.16"
|
"@radix-ui/react-context-menu": "npm:^2.2.16"
|
||||||
"@reduxjs/toolkit": "npm:^2.2.5"
|
"@reduxjs/toolkit": "npm:^2.2.5"
|
||||||
"@shikijs/markdown-it": "npm:^3.12.0"
|
"@shikijs/markdown-it": "npm:^3.12.0"
|
||||||
@@ -10219,7 +10219,6 @@ __metadata:
|
|||||||
p-queue: "npm:^8.1.0"
|
p-queue: "npm:^8.1.0"
|
||||||
pdf-lib: "npm:^1.17.1"
|
pdf-lib: "npm:^1.17.1"
|
||||||
pdf-parse: "npm:^1.1.1"
|
pdf-parse: "npm:^1.1.1"
|
||||||
playwright: "npm:^1.55.1"
|
|
||||||
proxy-agent: "npm:^6.5.0"
|
proxy-agent: "npm:^6.5.0"
|
||||||
qrcode.react: "npm:^4.2.0"
|
qrcode.react: "npm:^4.2.0"
|
||||||
react: "npm:^19.2.0"
|
react: "npm:^19.2.0"
|
||||||
@@ -20699,51 +20698,27 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"playwright-core@npm:1.52.0":
|
"playwright-core@npm:1.57.0":
|
||||||
version: 1.52.0
|
version: 1.57.0
|
||||||
resolution: "playwright-core@npm:1.52.0"
|
resolution: "playwright-core@npm:1.57.0"
|
||||||
bin:
|
bin:
|
||||||
playwright-core: cli.js
|
playwright-core: cli.js
|
||||||
checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f
|
checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"playwright-core@npm:1.56.1":
|
"playwright@npm:1.57.0":
|
||||||
version: 1.56.1
|
version: 1.57.0
|
||||||
resolution: "playwright-core@npm:1.56.1"
|
resolution: "playwright@npm:1.57.0"
|
||||||
bin:
|
|
||||||
playwright-core: cli.js
|
|
||||||
checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"playwright@npm:1.52.0":
|
|
||||||
version: 1.52.0
|
|
||||||
resolution: "playwright@npm:1.52.0"
|
|
||||||
dependencies:
|
dependencies:
|
||||||
fsevents: "npm:2.3.2"
|
fsevents: "npm:2.3.2"
|
||||||
playwright-core: "npm:1.52.0"
|
playwright-core: "npm:1.57.0"
|
||||||
dependenciesMeta:
|
dependenciesMeta:
|
||||||
fsevents:
|
fsevents:
|
||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
playwright: cli.js
|
playwright: cli.js
|
||||||
checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579
|
checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"playwright@npm:^1.55.1":
|
|
||||||
version: 1.56.1
|
|
||||||
resolution: "playwright@npm:1.56.1"
|
|
||||||
dependencies:
|
|
||||||
fsevents: "npm:2.3.2"
|
|
||||||
playwright-core: "npm:1.56.1"
|
|
||||||
dependenciesMeta:
|
|
||||||
fsevents:
|
|
||||||
optional: true
|
|
||||||
bin:
|
|
||||||
playwright: cli.js
|
|
||||||
checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4
|
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user