diff --git a/.oxlintrc.json b/.oxlintrc.json index 7d18f83c7..093ae25f1 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -11,6 +11,7 @@ "dist/**", "out/**", "local/**", + "tests/**", ".yarn/**", ".gitignore", "scripts/cloudflare-worker.js", diff --git a/eslint.config.mjs b/eslint.config.mjs index fcc952ed6..64fdefa1d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -58,6 +58,7 @@ export default defineConfig([ 'dist/**', 'out/**', 'local/**', + 'tests/**', '.yarn/**', '.gitignore', 'scripts/cloudflare-worker.js', diff --git a/package.json b/package.json index 5550405df..de89b4514 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@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", "@reduxjs/toolkit": "^2.2.5", "@shikijs/markdown-it": "^3.12.0", @@ -321,7 +321,6 @@ "p-queue": "^8.1.0", "pdf-lib": "^1.17.1", "pdf-parse": "^1.1.1", - "playwright": "^1.55.1", "proxy-agent": "^6.5.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index e12ce7ab6..0b67f0e76 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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({ - // Look for test files, relative to this configuration file. - testDir: './tests/e2e', - /* 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', + // Look for test files in the specs directory + testDir: './tests/e2e/specs', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry' + // Global timeout for each test + 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: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] } + name: 'electron', + 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, - // }, }) diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 000000000..6da89ddd6 --- /dev/null +++ b/tests/e2e/README.md @@ -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 { + 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) diff --git a/tests/e2e/fixtures/electron.fixture.ts b/tests/e2e/fixtures/electron.fixture.ts new file mode 100644 index 000000000..cf9def26e --- /dev/null +++ b/tests/e2e/fixtures/electron.fixture.ts @@ -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({ + 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' diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 000000000..edda731d5 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -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 diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts new file mode 100644 index 000000000..6336248e1 --- /dev/null +++ b/tests/e2e/global-teardown.ts @@ -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 diff --git a/tests/e2e/launch.test.tsx b/tests/e2e/launch.test.tsx deleted file mode 100644 index 8636c0169..000000000 --- a/tests/e2e/launch.test.tsx +++ /dev/null @@ -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() - }) -}) diff --git a/tests/e2e/pages/base.page.ts b/tests/e2e/pages/base.page.ts new file mode 100644 index 000000000..fe8065a65 --- /dev/null +++ b/tests/e2e/pages/base.page.ts @@ -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 { + 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 { + 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 { + const locator = this.page.locator(selector) + await locator.waitFor({ state: 'hidden', timeout }) + } + + /** + * Take a screenshot for debugging. + */ + async takeScreenshot(name: string): Promise { + 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 { + 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 { + 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 { + const input = this.page.locator(selector) + await input.fill(value) + } + + /** + * Get text content of an element. + */ + async getTextContent(selector: string): Promise { + const locator = this.page.locator(selector) + return locator.textContent() + } + + /** + * Check if an element is visible. + */ + async isElementVisible(selector: string): Promise { + const locator = this.page.locator(selector) + return locator.isVisible() + } + + /** + * Count elements matching a selector. + */ + async countElements(selector: string): Promise { + const locator = this.page.locator(selector) + return locator.count() + } +} diff --git a/tests/e2e/pages/chat.page.ts b/tests/e2e/pages/chat.page.ts new file mode 100644 index 000000000..c0b6b9181 --- /dev/null +++ b/tests/e2e/pages/chat.page.ts @@ -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 { + await this.navigateTo('/') + await this.chatContainer + .first() + .waitFor({ state: 'visible', timeout: 15000 }) + .catch(() => {}) + } + + /** + * Check if chat is visible. + */ + async isChatVisible(): Promise { + return this.chatContainer.first().isVisible() + } + + /** + * Type a message in the input area. + */ + async typeMessage(message: string): Promise { + await this.inputArea.first().fill(message) + } + + /** + * Clear the input area. + */ + async clearInput(): Promise { + await this.inputArea.first().clear() + } + + /** + * Click the send button. + */ + async clickSend(): Promise { + await this.sendButton.first().click() + } + + /** + * Type and send a message. + */ + async sendMessage(message: string): Promise { + await this.typeMessage(message) + await this.clickSend() + } + + /** + * Get the current input value. + */ + async getInputValue(): Promise { + return (await this.inputArea.first().inputValue()) || (await this.inputArea.first().textContent()) || '' + } + + /** + * Get the count of user messages. + */ + async getUserMessageCount(): Promise { + return this.userMessages.count() + } + + /** + * Get the count of assistant messages. + */ + async getAssistantMessageCount(): Promise { + return this.assistantMessages.count() + } + + /** + * Check if send button is enabled. + */ + async isSendButtonEnabled(): Promise { + const isDisabled = await this.sendButton.first().isDisabled() + return !isDisabled + } + + /** + * Create a new topic/conversation. + */ + async createNewTopic(): Promise { + await this.newTopicButton.first().click() + } + + /** + * Check if stop button is visible (indicates ongoing generation). + */ + async isGenerating(): Promise { + return this.stopButton.first().isVisible() + } + + /** + * Click stop button to stop generation. + */ + async stopGeneration(): Promise { + await this.stopButton.first().click() + } + + /** + * Wait for generation to complete. + */ + async waitForGenerationComplete(timeout: number = 60000): Promise { + await this.stopButton.first().waitFor({ state: 'hidden', timeout }) + } +} diff --git a/tests/e2e/pages/home.page.ts b/tests/e2e/pages/home.page.ts new file mode 100644 index 000000000..4d3efb88a --- /dev/null +++ b/tests/e2e/pages/home.page.ts @@ -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 { + await this.navigateTo('/') + await this.homePage + .first() + .waitFor({ state: 'visible', timeout: 15000 }) + .catch(() => {}) + } + + /** + * Check if the home page is loaded. + */ + async isLoaded(): Promise { + return this.homePage.first().isVisible() + } + + /** + * Type a message in the input area. + */ + async typeMessage(message: string): Promise { + 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 { + await this.sendButton.first().click() + } + + /** + * Type and send a message. + */ + async sendChatMessage(message: string): Promise { + await this.typeMessage(message) + await this.sendMessage() + } + + /** + * Get the count of messages in the chat. + */ + async getMessageCount(): Promise { + const messages = this.page.locator('[class*="Message"]:not([class*="Messages"]):not([class*="MessageList"])') + return messages.count() + } + + /** + * Create a new topic/conversation. + */ + async createNewTopic(): Promise { + await this.newTopicButton.first().click() + } + + /** + * Check if the chat interface is visible. + */ + async isChatVisible(): Promise { + return this.chatContainer.first().isVisible() + } + + /** + * Check if the input bar is visible. + */ + async isInputBarVisible(): Promise { + return this.inputBar.first().isVisible() + } + + /** + * Get the placeholder text of the input field. + */ + async getInputPlaceholder(): Promise { + const input = this.page.locator('[class*="Inputbar"] textarea, [class*="InputBar"] textarea') + return input.first().getAttribute('placeholder') + } +} diff --git a/tests/e2e/pages/index.ts b/tests/e2e/pages/index.ts new file mode 100644 index 000000000..453b8fa53 --- /dev/null +++ b/tests/e2e/pages/index.ts @@ -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' diff --git a/tests/e2e/pages/settings.page.ts b/tests/e2e/pages/settings.page.ts new file mode 100644 index 000000000..44fd2b683 --- /dev/null +++ b/tests/e2e/pages/settings.page.ts @@ -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 { + await this.navigateTo('/settings/provider') + await this.waitForElement('[id="content-container"], [class*="Settings"]') + } + + /** + * Check if settings page is loaded. + */ + async isLoaded(): Promise { + return this.settingsContainer.first().isVisible() + } + + /** + * Navigate to Provider settings. + */ + async goToProvider(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const className = await menuItem.getAttribute('class') + return className?.includes('active') || className?.includes('selected') || false + } +} diff --git a/tests/e2e/pages/sidebar.page.ts b/tests/e2e/pages/sidebar.page.ts new file mode 100644 index 000000000..a65c33216 --- /dev/null +++ b/tests/e2e/pages/sidebar.page.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.sidebar.first().isVisible() + } +} diff --git a/tests/e2e/specs/app-launch.spec.ts b/tests/e2e/specs/app-launch.spec.ts new file mode 100644 index 000000000..0a58c64fb --- /dev/null +++ b/tests/e2e/specs/app-launch.spec.ts @@ -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) + }) +}) diff --git a/tests/e2e/specs/conversation/basic-chat.spec.ts b/tests/e2e/specs/conversation/basic-chat.spec.ts new file mode 100644 index 000000000..2e03ed4ed --- /dev/null +++ b/tests/e2e/specs/conversation/basic-chat.spec.ts @@ -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) + }) +}) diff --git a/tests/e2e/specs/navigation.spec.ts b/tests/e2e/specs/navigation.spec.ts new file mode 100644 index 000000000..085bff393 --- /dev/null +++ b/tests/e2e/specs/navigation.spec.ts @@ -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(/#\/?$|#$/) + }) +}) diff --git a/tests/e2e/specs/settings/general.spec.ts b/tests/e2e/specs/settings/general.spec.ts new file mode 100644 index 000000000..6943cf350 --- /dev/null +++ b/tests/e2e/specs/settings/general.spec.ts @@ -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') + }) +}) diff --git a/tests/e2e/utils/index.ts b/tests/e2e/utils/index.ts new file mode 100644 index 000000000..908302f02 --- /dev/null +++ b/tests/e2e/utils/index.ts @@ -0,0 +1,4 @@ +/** + * Export all utilities for easy importing. + */ +export * from './wait-helpers' diff --git a/tests/e2e/utils/wait-helpers.ts b/tests/e2e/utils/wait-helpers.ts new file mode 100644 index 000000000..f2ad09ccc --- /dev/null +++ b/tests/e2e/utils/wait-helpers.ts @@ -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 { + // 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 { + await page.waitForURL(`**/#${path}**`, { timeout }) +} + +/** + * Wait for the chat interface to be ready. + */ +export async function waitForChatReady(page: Page, timeout: number = 30000): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/yarn.lock b/yarn.lock index 02d11ef5d..7f7ed62da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5437,14 +5437,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:^1.52.0": - version: 1.52.0 - resolution: "@playwright/test@npm:1.52.0" +"@playwright/test@npm:^1.55.1": + version: 1.57.0 + resolution: "@playwright/test@npm:1.57.0" dependencies: - playwright: "npm:1.52.0" + playwright: "npm:1.57.0" bin: playwright: cli.js - checksum: 10c0/1c428b421593eb4f79b7c99783a389c3ab3526c9051ec772749f4fca61414dfa9f2344eba846faac5f238084aa96c836364a91d81d3034ac54924f239a93e247 + checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774 languageName: node linkType: hard @@ -10059,7 +10059,7 @@ __metadata: "@opentelemetry/sdk-trace-web": "npm:^2.0.0" "@opeoginni/github-copilot-openai-compatible": "npm:^0.1.21" "@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" "@reduxjs/toolkit": "npm:^2.2.5" "@shikijs/markdown-it": "npm:^3.12.0" @@ -10219,7 +10219,6 @@ __metadata: p-queue: "npm:^8.1.0" pdf-lib: "npm:^1.17.1" pdf-parse: "npm:^1.1.1" - playwright: "npm:^1.55.1" proxy-agent: "npm:^6.5.0" qrcode.react: "npm:^4.2.0" react: "npm:^19.2.0" @@ -20699,51 +20698,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.52.0": - version: 1.52.0 - resolution: "playwright-core@npm:1.52.0" +"playwright-core@npm:1.57.0": + version: 1.57.0 + resolution: "playwright-core@npm:1.57.0" bin: playwright-core: cli.js - checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f + checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9 languageName: node linkType: hard -"playwright-core@npm:1.56.1": - version: 1.56.1 - resolution: "playwright-core@npm:1.56.1" - 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" +"playwright@npm:1.57.0": + version: 1.57.0 + resolution: "playwright@npm:1.57.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.52.0" + playwright-core: "npm:1.57.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579 - 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 + checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899 languageName: node linkType: hard