From b6f1fc4a14eb934aa88977e7358b6451e3c66d94 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Thu, 23 Oct 2025 02:15:24 +0800 Subject: [PATCH] =?UTF-8?q?=20=E2=9C=85=20test:=20add=20BDD=20test=20frame?= =?UTF-8?q?work=20and=20initial=20tests=20with=20Playwright=20and=20Cucumb?= =?UTF-8?q?er=20(#9843)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * try with bdd test * update * update * add workspace * update * fix * ci * ci * fix * update * update * update parallel * update config * โšก๏ธ perf: increase e2e timeout to 120 seconds Co-authored-by: Arvin Xu * update config * more parallel * fix parallel * fix tests * refactor to improve performance * fix * fix * fix * refactor with tsx --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Arvin Xu --- .github/PULL_REQUEST_TEMPLATE.md | 26 ++++ .github/workflows/e2e.yml | 12 +- e2e/README.md | 143 ++++++++++++++++++ e2e/cucumber.config.js | 20 +++ e2e/package.json | 24 +++ e2e/routes.spec.ts | 73 --------- e2e/src/features/discover/smoke.feature | 11 ++ e2e/src/features/routes/core-routes.feature | 43 ++++++ e2e/src/steps/common/navigation.steps.ts | 36 +++++ e2e/src/steps/discover/smoke.steps.ts | 34 +++++ e2e/src/steps/hooks.ts | 69 +++++++++ e2e/src/steps/routes/routes.steps.ts | 41 +++++ e2e/src/support/webServer.ts | 96 ++++++++++++ e2e/src/support/world.ts | 76 ++++++++++ e2e/tsconfig.json | 19 +++ package.json | 7 +- playwright.config.ts | 35 ----- pnpm-workspace.yaml | 1 + .../(list)/assistant/features/List/Item.tsx | 1 + .../(main)/discover/DiscoverRouter.tsx | 24 +-- .../(main)/discover/[[...path]]/page.tsx | 13 +- .../(main)/discover/features/Search.tsx | 1 + src/components/Loading/index.ts | 1 + 23 files changed, 673 insertions(+), 133 deletions(-) create mode 100644 e2e/README.md create mode 100644 e2e/cucumber.config.js create mode 100644 e2e/package.json delete mode 100644 e2e/routes.spec.ts create mode 100644 e2e/src/features/discover/smoke.feature create mode 100644 e2e/src/features/routes/core-routes.feature create mode 100644 e2e/src/steps/common/navigation.steps.ts create mode 100644 e2e/src/steps/discover/smoke.steps.ts create mode 100644 e2e/src/steps/hooks.ts create mode 100644 e2e/src/steps/routes/routes.steps.ts create mode 100644 e2e/src/support/webServer.ts create mode 100644 e2e/src/support/world.ts create mode 100644 e2e/tsconfig.json delete mode 100644 playwright.config.ts create mode 100644 src/components/Loading/index.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 08f85ef3da..4410c6f918 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,10 +12,36 @@ - [ ] ๐Ÿ“ docs - [ ] ๐Ÿ”จ chore +#### ๐Ÿ”— Related Issue + + + + + #### ๐Ÿ”€ Description of Change +#### ๐Ÿงช How to Test + + + + + +- [ ] Tested locally +- [ ] Added/updated tests +- [ ] No tests needed + +#### ๐Ÿ“ธ Screenshots / Videos + + + +| Before | After | +| ------ | ----- | +| ... | ... | + #### ๐Ÿ“ Additional Information + + diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e0e5ddac3d..fe4642ca31 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -35,18 +35,18 @@ jobs: PORT: 3010 run: bun run e2e - - name: Upload Playwright HTML report (on failure) + - name: Upload Cucumber HTML report (on failure) if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report - path: playwright-report + name: cucumber-report + path: e2e/reports if-no-files-found: ignore - - name: Upload Playwright traces (on failure) + - name: Upload screenshots (on failure) if: failure() uses: actions/upload-artifact@v4 with: - name: test-results - path: test-results + name: test-screenshots + path: e2e/screenshots if-no-files-found: ignore diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000000..a4fbf19a63 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,143 @@ +# E2E Tests for LobeChat + +This directory contains end-to-end (E2E) tests for LobeChat using Cucumber (BDD) and Playwright. + +## Directory Structure + +```` +e2e/ +โ”œโ”€โ”€ src/ # Source files +โ”‚ โ”œโ”€โ”€ features/ # Gherkin feature files +โ”‚ โ”‚ โ””โ”€โ”€ discover/ # Discover page tests +โ”‚ โ”œโ”€โ”€ steps/ # Step definitions +โ”‚ โ”‚ โ”œโ”€โ”€ common/ # Reusable step definitions +โ”‚ โ”‚ โ””โ”€โ”€ discover/ # Discover-specific steps +โ”‚ โ””โ”€โ”€ support/ # Test support files +โ”‚ โ””โ”€โ”€ world.ts # Custom World context +โ”œโ”€โ”€ reports/ # Test reports (generated) +โ”œโ”€โ”€ cucumber.config.js # Cucumber configuration +โ”œโ”€โ”€ tsconfig.json # TypeScript configuration +โ””โ”€โ”€ package.json # Dependencies and scripts + +## Prerequisites + +- Node.js 20, 22, or >=24 +- Dev server running on `http://localhost:3010` (or set `BASE_URL` env var) + +## Installation + +Install dependencies: + +```bash +cd e2e +pnpm install +```` + +Install Playwright browsers: + +```bash +npx playwright install chromium +``` + +## Running Tests + +Run all tests: + +```bash +npm test +``` + +Run tests in headed mode (see browser): + +```bash +npm run test:headed +``` + +Run only smoke tests: + +```bash +npm run test:smoke +``` + +Run discover tests: + +```bash +npm run test:discover +``` + +## Environment Variables + +- `BASE_URL`: Base URL for the application (default: `http://localhost:3010`) +- `PORT`: Port number (default: `3010`) +- `HEADLESS`: Run browser in headless mode (default: `true`, set to `false` to see browser) + +Example: + +```bash +HEADLESS=false BASE_URL=http://localhost:3000 npm run test:smoke +``` + +## Writing Tests + +### Feature Files + +Feature files are written in Gherkin syntax and placed in the `src/features/` directory: + +```gherkin +@discover @smoke +Feature: Discover Smoke Tests + Critical path tests to ensure the discover module is functional + + @DISCOVER-SMOKE-001 @P0 + Scenario: Load discover assistant list page + Given I navigate to "/discover/assistant" + Then the page should load without errors + And I should see the page body + And I should see the search bar + And I should see assistant cards +``` + +### Step Definitions + +Step definitions are TypeScript files in the `src/steps/` directory that implement the steps from feature files: + +```typescript +import { Given, Then } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; + +import { CustomWorld } from '../../support/world'; + +Given('I navigate to {string}', async function (this: CustomWorld, path: string) { + await this.page.goto(path); + await this.page.waitForLoadState('domcontentloaded'); +}); +``` + +## Test Reports + +After running tests, HTML and JSON reports are generated in the `reports/` directory: + +- `reports/cucumber-report.html` - Human-readable HTML report +- `reports/cucumber-report.json` - Machine-readable JSON report + +## Troubleshooting + +### Browser not found + +If you see errors about missing browser executables: + +```bash +npx playwright install chromium +``` + +### Port already in use + +Make sure the dev server is running on the expected port (3010 by default), or set `PORT` or `BASE_URL` environment variable. + +### Test timeout + +Increase timeout in `cucumber.config.js` or `src/steps/hooks.ts`: + +```typescript +setDefaultTimeout(120000); // 2 minutes +``` diff --git a/e2e/cucumber.config.js b/e2e/cucumber.config.js new file mode 100644 index 0000000000..220bebfeb3 --- /dev/null +++ b/e2e/cucumber.config.js @@ -0,0 +1,20 @@ +/** + * @type {import('@cucumber/cucumber').IConfiguration} + */ +export default { + format: [ + 'progress-bar', + 'html:reports/cucumber-report.html', + 'json:reports/cucumber-report.json', + ], + formatOptions: { + snippetInterface: 'async-await', + }, + parallel: process.env.CI ? 1 : 4, + paths: ['src/features/**/*.feature'], + publishQuiet: true, + require: ['src/steps/**/*.ts', 'src/support/**/*.ts'], + requireModule: ['tsx/cjs'], + retry: 0, + timeout: 120_000, +}; diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000000..2d56f16063 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,24 @@ +{ + "name": "@lobechat/e2e-tests", + "version": "0.1.0", + "private": true, + "description": "E2E tests for LobeChat using Cucumber and Playwright", + "scripts": { + "test": "cucumber-js --config cucumber.config.js", + "test:discover": "cucumber-js --config cucumber.config.js src/features/discover/", + "test:headed": "HEADLESS=false cucumber-js --config cucumber.config.js", + "test:routes": "cucumber-js --config cucumber.config.js --tags '@routes'", + "test:routes:ci": "cucumber-js --config cucumber.config.js --tags '@routes and not @ci-skip'", + "test:smoke": "cucumber-js --config cucumber.config.js --tags '@smoke'" + }, + "dependencies": { + "@cucumber/cucumber": "^12.2.0", + "@playwright/test": "^1.56.1", + "playwright": "^1.56.1" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "tsx": "^4.20.6", + "typescript": "^5.7.3" + } +} diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts deleted file mode 100644 index 8885ff3e40..0000000000 --- a/e2e/routes.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { expect, test } from '@playwright/test'; - -// ่ฆ†็›–ๆ ธๅฟƒๅฏ่ฎฟ้—ฎ่ทฏๅพ„๏ผˆๅซ้‡ๅฎšๅ‘ๆฅๆบ๏ผ‰ -const baseRoutes: string[] = [ - '/', - '/chat', - '/discover', - '/image', - '/files', - '/repos', // next.config.ts -> /files - '/changelog', -]; - -// settings ่ทฏ็”ฑๆ”นไธบ้€š่ฟ‡ query ๅ‚ๆ•ฐๆŽงๅˆถ active tab -// ๅ‚่€ƒ SettingsTabs: about, agent, common, hotkey, llm, provider, proxy, storage, system-agent, tts -const settingsTabs = [ - 'common', - 'llm', - 'provider', - 'about', - 'hotkey', - 'proxy', - 'storage', - 'tts', - 'system-agent', - 'agent', -]; - -const routes: string[] = [...baseRoutes, ...settingsTabs.map((key) => `/settings?active=${key}`)]; - -// CI ็Žฏๅขƒไธ‹่ทณ่ฟ‡ๅฎนๆ˜“ไธ็จณๅฎšๆˆ–ๅ—็‰นๆ€งๅผ€ๅ…ณๅฝฑๅ“็š„่ทฏ็”ฑ -const ciSkipPaths = new Set([ - '/image', - '/changelog', - '/settings?active=common', - '/settings?active=llm', -]); - -// @ts-ignore -async function assertNoPageErrors(page: Parameters[0]['page']) { - const pageErrors: Error[] = []; - const consoleErrors: string[] = []; - - page.on('pageerror', (err: Error) => pageErrors.push(err)); - page.on('console', (msg: any) => { - if (msg.type() === 'error') consoleErrors.push(msg.text()); - }); - - // ไป…ๆ ก้ชŒ้กต้ข็บง้”™่ฏฏ๏ผŒๅฟฝ็•ฅๆŽงๅˆถๅฐ error ไปฅๆๅ‡็จณๅฎšๆ€ง - expect - .soft(pageErrors, `page errors: ${pageErrors.map((e) => e.message).join('\n')}`) - .toHaveLength(0); -} - -test.describe('Smoke: core routes', () => { - for (const path of routes) { - test(`should open ${path} without error`, async ({ page }) => { - if (process.env.CI && ciSkipPaths.has(path)) test.skip(true, 'skip flaky route on CI'); - const response = await page.goto(path, { waitUntil: 'commit' }); - // 2xx ๆˆ– 3xx ่ง†ไธบๅฏๆŽฅๅ—๏ผˆๅ…่ฎธไธญ้—ดไปถ/้‡ๅฎšๅ‘๏ผ‰ - const status = response?.status() ?? 0; - expect(status, `unexpected status for ${path}: ${status}`).toBeLessThan(400); - - // ไธ€่ˆฌ้”™่ฏฏๆ ‡้ข˜้˜ฒๅพก - await expect(page).not.toHaveTitle(/not found|error/i); - - // body ๅฏ่ง - await expect(page.locator('body')).toBeVisible(); - - await assertNoPageErrors(page); - }); - } -}); diff --git a/e2e/src/features/discover/smoke.feature b/e2e/src/features/discover/smoke.feature new file mode 100644 index 0000000000..e7ab5bf176 --- /dev/null +++ b/e2e/src/features/discover/smoke.feature @@ -0,0 +1,11 @@ +@discover @smoke +Feature: Discover Smoke Tests + Critical path tests to ensure the discover module is functional + + @DISCOVER-SMOKE-001 @P0 + Scenario: Load discover assistant list page + Given I navigate to "/discover/assistant" + Then the page should load without errors + And I should see the page body + And I should see the search bar + And I should see assistant cards diff --git a/e2e/src/features/routes/core-routes.feature b/e2e/src/features/routes/core-routes.feature new file mode 100644 index 0000000000..3555aadad2 --- /dev/null +++ b/e2e/src/features/routes/core-routes.feature @@ -0,0 +1,43 @@ +@routes @smoke +Feature: Core Routes Accessibility + As a user + I want all core application routes to be accessible + So that I can navigate the application without errors + + Background: + Given the application is running + + @ROUTES-001 @P0 + Scenario Outline: Access core routes without errors + When I navigate to "" + Then the response status should be less than 400 + And the page should load without errors + And I should see the page body + And the page title should not contain "error" or "not found" + + Examples: + | route | + | / | + | /chat | + | /discover | + | /files | + | /repos | + + @ROUTES-002 @P0 + Scenario Outline: Access settings routes without errors + When I navigate to "/settings?active=" + Then the response status should be less than 400 + And the page should load without errors + And I should see the page body + And the page title should not contain "error" or "not found" + + Examples: + | tab | + | about | + | agent | + | hotkey | + | provider | + | proxy | + | storage | + | system-agent | + | tts | diff --git a/e2e/src/steps/common/navigation.steps.ts b/e2e/src/steps/common/navigation.steps.ts new file mode 100644 index 0000000000..ba8c98ad73 --- /dev/null +++ b/e2e/src/steps/common/navigation.steps.ts @@ -0,0 +1,36 @@ +import { Given, Then } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; + +import { CustomWorld } from '../../support/world'; + +// ============================================ +// Given Steps (Preconditions) +// ============================================ + +Given('I navigate to {string}', async function (this: CustomWorld, path: string) { + const response = await this.page.goto(path, { waitUntil: 'commit' }); + this.testContext.lastResponse = response; + await this.page.waitForLoadState('domcontentloaded'); +}); + +// ============================================ +// Then Steps (Assertions) +// ============================================ + +Then('the page should load without errors', async function (this: CustomWorld) { + // Check for no JavaScript errors + expect(this.testContext.jsErrors).toHaveLength(0); + + // Check page didn't navigate to error page + const url = this.page.url(); + expect(url).not.toMatch(/\/404|\/error|not-found/i); + + // Check no error title + const title = await this.page.title(); + expect(title).not.toMatch(/not found|error/i); +}); + +Then('I should see the page body', async function (this: CustomWorld) { + const body = this.page.locator('body'); + await expect(body).toBeVisible(); +}); diff --git a/e2e/src/steps/discover/smoke.steps.ts b/e2e/src/steps/discover/smoke.steps.ts new file mode 100644 index 0000000000..a9807b8c27 --- /dev/null +++ b/e2e/src/steps/discover/smoke.steps.ts @@ -0,0 +1,34 @@ +import { Then } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; + +import { CustomWorld } from '../../support/world'; + +// ============================================ +// Then Steps (Assertions) +// ============================================ + +Then('I should see the search bar', async function (this: CustomWorld) { + // Wait for network to be idle to ensure Suspense components are loaded + await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + + // The SearchBar component from @lobehub/ui may not pass through data-testid + // Try to find the input element within the search component + const searchBar = this.page.locator('input[type="text"]').first(); + await expect(searchBar).toBeVisible({ timeout: 120_000 }); +}); + +Then('I should see assistant cards', async function (this: CustomWorld) { + // Wait for content to load + await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + + // After migrating to SPA (react-router), links use relative paths like /assistant/:id + // Look for assistant items by data-testid instead of href + const assistantItems = this.page.locator('[data-testid="assistant-item"]'); + + // Wait for at least one item to be visible + await expect(assistantItems.first()).toBeVisible({ timeout: 120_000 }); + + // Check we have multiple items + const count = await assistantItems.count(); + expect(count).toBeGreaterThan(0); +}); diff --git a/e2e/src/steps/hooks.ts b/e2e/src/steps/hooks.ts new file mode 100644 index 0000000000..2b8033b0f9 --- /dev/null +++ b/e2e/src/steps/hooks.ts @@ -0,0 +1,69 @@ +import { After, AfterAll, Before, BeforeAll, Status, setDefaultTimeout } from '@cucumber/cucumber'; + +import { startWebServer, stopWebServer } from '../support/webServer'; +import { CustomWorld } from '../support/world'; + +// Set default timeout for all steps to 120 seconds +setDefaultTimeout(120_000); + +BeforeAll({ timeout: 120_000 }, async function () { + console.log('๐Ÿš€ Starting E2E test suite...'); + + const PORT = process.env.PORT ? Number(process.env.PORT) : 3010; + const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; + + console.log(`Base URL: ${BASE_URL}`); + + // Start web server if not using external BASE_URL + if (!process.env.BASE_URL) { + await startWebServer({ + command: 'npm run dev', + port: PORT, + reuseExistingServer: !process.env.CI, + }); + } +}); + +Before(async function (this: CustomWorld, { pickle }) { + await this.init(); + + const testId = pickle.tags.find((tag) => tag.name.startsWith('@DISCOVER-')); + console.log(`\n๐Ÿ“ Running: ${pickle.name}${testId ? ` (${testId.name.replace('@', '')})` : ''}`); +}); + +After(async function (this: CustomWorld, { pickle, result }) { + const testId = pickle.tags + .find((tag) => tag.name.startsWith('@DISCOVER-')) + ?.name.replace('@', ''); + + if (result?.status === Status.FAILED) { + const screenshot = await this.takeScreenshot(`${testId || 'failure'}-${Date.now()}`); + this.attach(screenshot, 'image/png'); + + const html = await this.page.content(); + this.attach(html, 'text/html'); + + if (this.testContext.jsErrors.length > 0) { + const errors = this.testContext.jsErrors.map((e) => e.message).join('\n'); + this.attach(`JavaScript Errors:\n${errors}`, 'text/plain'); + } + + console.log(`โŒ Failed: ${pickle.name}`); + if (result.message) { + console.log(` Error: ${result.message}`); + } + } else if (result?.status === Status.PASSED) { + console.log(`โœ… Passed: ${pickle.name}`); + } + + await this.cleanup(); +}); + +AfterAll(async function () { + console.log('\n๐Ÿ Test suite completed'); + + // Stop web server if we started it + if (!process.env.BASE_URL && process.env.CI) { + await stopWebServer(); + } +}); diff --git a/e2e/src/steps/routes/routes.steps.ts b/e2e/src/steps/routes/routes.steps.ts new file mode 100644 index 0000000000..6d1aa7c584 --- /dev/null +++ b/e2e/src/steps/routes/routes.steps.ts @@ -0,0 +1,41 @@ +import { Given, Then } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; + +import { CustomWorld } from '../../support/world'; + +// ============================================ +// Given Steps (Preconditions) +// ============================================ + +Given('the application is running', async function (this: CustomWorld) { + // This is a placeholder step to indicate that the app should be running + // The actual server startup is handled outside the test (in CI or locally) + // We just verify we can reach the base URL + const response = await this.page.goto('/'); + expect(response).toBeTruthy(); + // Store the response for later assertions + this.testContext.lastResponse = response; +}); + +// ============================================ +// Then Steps (Assertions) +// ============================================ + +Then( + 'the response status should be less than {int}', + async function (this: CustomWorld, maxStatus: number) { + const status = this.testContext.lastResponse?.status() ?? 0; + expect(status, `Expected status < ${maxStatus}, but got ${status}`).toBeLessThan(maxStatus); + }, +); + +Then( + 'the page title should not contain {string} or {string}', + async function (this: CustomWorld, text1: string, text2: string) { + const title = await this.page.title(); + const regex = new RegExp(`${text1}|${text2}`, 'i'); + expect(title, `Page title "${title}" should not contain "${text1}" or "${text2}"`).not.toMatch( + regex, + ); + }, +); diff --git a/e2e/src/support/webServer.ts b/e2e/src/support/webServer.ts new file mode 100644 index 0000000000..977a442803 --- /dev/null +++ b/e2e/src/support/webServer.ts @@ -0,0 +1,96 @@ +import { type ChildProcess, exec } from 'node:child_process'; +import { resolve } from 'node:path'; + +let serverProcess: ChildProcess | null = null; +let serverStartPromise: Promise | null = null; + +interface WebServerOptions { + command: string; + env?: Record; + port: number; + reuseExistingServer?: boolean; + timeout?: number; +} + +async function isServerRunning(port: number): Promise { + try { + const response = await fetch(`http://localhost:${port}/chat`, { + method: 'HEAD', + }); + return response.ok; + } catch { + return false; + } +} + +export async function startWebServer(options: WebServerOptions): Promise { + const { command, port, timeout = 120_000, env = {}, reuseExistingServer = true } = options; + + // If server is already being started by another worker, wait for it + if (serverStartPromise) { + console.log(`โณ Waiting for server to start (started by another worker)...`); + return serverStartPromise; + } + + // Check if server is already running + if (reuseExistingServer && (await isServerRunning(port))) { + console.log(`โœ… Reusing existing server on port ${port}`); + return; + } + + // Create a promise for the server startup and store it + serverStartPromise = (async () => { + console.log(`๐Ÿš€ Starting web server: ${command}`); + + // Get the project root directory (parent of e2e folder) + const projectRoot = resolve(__dirname, '../../..'); + + // Start the server process + serverProcess = exec(command, { + cwd: projectRoot, + env: { + ...process.env, + ENABLE_AUTH_PROTECTION: '0', + ENABLE_OIDC: '0', + NEXT_PUBLIC_ENABLE_CLERK_AUTH: '0', + NEXT_PUBLIC_ENABLE_NEXT_AUTH: '0', + NODE_OPTIONS: '--max-old-space-size=6144', + PORT: String(port), + ...env, + }, + }); + + // Forward server output to console for debugging + serverProcess.stdout?.on('data', (data) => { + console.log(`[server] ${data}`); + }); + + serverProcess.stderr?.on('data', (data) => { + console.error(`[server] ${data}`); + }); + + // Wait for server to be ready + const startTime = Date.now(); + while (!(await isServerRunning(port))) { + if (Date.now() - startTime > timeout) { + throw new Error(`Server failed to start within ${timeout}ms`); + } + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + } + + console.log(`โœ… Web server is ready on port ${port}`); + })(); + + return serverStartPromise; +} + +export async function stopWebServer(): Promise { + if (serverProcess) { + console.log('๐Ÿ›‘ Stopping web server...'); + serverProcess.kill(); + serverProcess = null; + serverStartPromise = null; + } +} diff --git a/e2e/src/support/world.ts b/e2e/src/support/world.ts new file mode 100644 index 0000000000..3d4cf650c5 --- /dev/null +++ b/e2e/src/support/world.ts @@ -0,0 +1,76 @@ +import { IWorldOptions, World, setWorldConstructor } from '@cucumber/cucumber'; +import { Browser, BrowserContext, Page, Response, chromium } from '@playwright/test'; + +export interface TestContext { + [key: string]: any; + consoleErrors: string[]; + jsErrors: Error[]; + lastResponse?: Response | null; + previousUrl?: string; +} + +export class CustomWorld extends World { + browser!: Browser; + browserContext!: BrowserContext; + page!: Page; + testContext: TestContext; + + constructor(options: IWorldOptions) { + super(options); + this.testContext = { + consoleErrors: [], + jsErrors: [], + }; + } + + // Getter for easier access + get context(): TestContext { + return this.testContext; + } + + async init() { + const PORT = process.env.PORT ? Number(process.env.PORT) : 3010; + const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; + + this.browser = await chromium.launch({ + headless: process.env.HEADLESS !== 'false', + }); + + this.browserContext = await this.browser.newContext({ + baseURL: BASE_URL, + viewport: { height: 720, width: 1280 }, + }); + + // Set expect timeout for assertions (e.g., toBeVisible, toHaveText) + this.browserContext.setDefaultTimeout(120_000); + + this.page = await this.browserContext.newPage(); + + // Set up error listeners + this.page.on('pageerror', (error) => { + this.testContext.jsErrors.push(error); + console.error('Page error:', error.message); + }); + + this.page.on('console', (msg) => { + if (msg.type() === 'error') { + this.testContext.consoleErrors.push(msg.text()); + } + }); + + this.page.setDefaultTimeout(120_000); + } + + async cleanup() { + await this.page?.close(); + await this.browserContext?.close(); + await this.browser?.close(); + } + + async takeScreenshot(name: string): Promise { + console.log(name); + return await this.page.screenshot({ fullPage: true }); + } +} + +setWorldConstructor(CustomWorld); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000000..e3b8b712a9 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "target": "ES2020", + "lib": ["ES2020"], + "types": ["node", "@cucumber/cucumber", "@playwright/test"], + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["../*"] + } + }, + "exclude": ["node_modules", "reports"], + "extends": "../tsconfig.json", + "include": ["src/**/*", "*.js"] +} diff --git a/package.json b/package.json index 62c34862e6..8b03919dde 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "author": "LobeHub ", "sideEffects": false, "workspaces": [ - "packages/*" + "packages/*", + "e2e" ], "scripts": { "prebuild": "tsx scripts/prebuild.mts && npm run lint", @@ -54,7 +55,7 @@ "dev:mobile": "next dev --turbopack -p 3018", "docs:i18n": "lobe-i18n md && npm run lint:md && npm run lint:mdx && prettier -c --write locales/**/*", "docs:seo": "lobe-seo && npm run lint:mdx", - "e2e": "playwright test", + "e2e": "cd e2e && npm run test:smoke", "e2e:install": "playwright install", "e2e:ui": "playwright test --ui", "i18n": "npm run workflow:i18n && lobe-i18n && prettier -c --write \"locales/**\"", @@ -80,6 +81,8 @@ "test": "npm run test-app && npm run test-server", "test-app": "vitest run", "test-app:coverage": "vitest --coverage --silent='passed-only'", + "test:e2e": "pnpm --filter @lobechat/e2e-tests test", + "test:e2e:smoke": "pnpm --filter @lobechat/e2e-tests test:smoke", "test:update": "vitest -u", "type-check": "tsgo --noEmit", "webhook:ngrok": "ngrok http http://localhost:3011", diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 4b8d08ba35..0000000000 --- a/playwright.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -const PORT = process.env.PORT ? Number(process.env.PORT) : 3010; - -export default defineConfig({ - expect: { timeout: 10_000 }, - fullyParallel: true, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], - reporter: 'list', - retries: 0, - testDir: './e2e', - timeout: 1_200_000, - use: { - baseURL: `http://localhost:${PORT}`, - trace: 'on-first-retry', - }, - webServer: { - command: 'npm run dev', - env: { - ENABLE_AUTH_PROTECTION: '0', - ENABLE_OIDC: '0', - NEXT_PUBLIC_ENABLE_CLERK_AUTH: '0', - NEXT_PUBLIC_ENABLE_NEXT_AUTH: '0', - NODE_OPTIONS: '--max-old-space-size=6144', - }, - reuseExistingServer: true, - timeout: 120_000, - url: `http://localhost:${PORT}/chat`, - }, -}); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 466721bc4c..adf9741840 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - 'packages/**' - '.' + - 'e2e' - '!apps/**' diff --git a/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx b/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx index 3e414295c7..4320fa2b6d 100644 --- a/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx +++ b/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx @@ -69,6 +69,7 @@ const AssistantItem = memo( return ( { navigate(link); diff --git a/src/app/[variants]/(main)/discover/DiscoverRouter.tsx b/src/app/[variants]/(main)/discover/DiscoverRouter.tsx index 13a64aed45..0b887e91db 100644 --- a/src/app/[variants]/(main)/discover/DiscoverRouter.tsx +++ b/src/app/[variants]/(main)/discover/DiscoverRouter.tsx @@ -1,24 +1,24 @@ 'use client'; import { memo, useEffect } from 'react'; -import { MemoryRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { useMediaQuery } from 'react-responsive'; +import { MemoryRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; -import DiscoverLayout from './_layout/DiscoverLayout'; -import ListLayout from './(list)/_layout/ListLayout'; import DetailLayout from './(detail)/_layout/DetailLayout'; -import HomePage from './(list)/(home)/HomePage'; -import AssistantPage from './(list)/assistant/AssistantPage'; -import AssistantLayout from './(list)/assistant/AssistantLayout'; -import McpPage from './(list)/mcp/McpPage'; -import McpLayout from './(list)/mcp/McpLayout'; -import ModelPage from './(list)/model/ModelPage'; -import ModelLayout from './(list)/model/ModelLayout'; -import ProviderPage from './(list)/provider/ProviderPage'; import AssistantDetailPage from './(detail)/assistant/AssistantDetailPage'; import McpDetailPage from './(detail)/mcp/McpDetailPage'; import ModelDetailPage from './(detail)/model/ModelDetailPage'; import ProviderDetailPage from './(detail)/provider/ProviderDetailPage'; +import HomePage from './(list)/(home)/HomePage'; +import ListLayout from './(list)/_layout/ListLayout'; +import AssistantLayout from './(list)/assistant/AssistantLayout'; +import AssistantPage from './(list)/assistant/AssistantPage'; +import McpLayout from './(list)/mcp/McpLayout'; +import McpPage from './(list)/mcp/McpPage'; +import ModelLayout from './(list)/model/ModelLayout'; +import ModelPage from './(list)/model/ModelPage'; +import ProviderPage from './(list)/provider/ProviderPage'; +import DiscoverLayout from './_layout/DiscoverLayout'; // Get initial path from URL const getInitialPath = () => { @@ -164,4 +164,6 @@ const DiscoverRouter = memo(() => { ); }); +DiscoverRouter.displayName = 'DiscoverRouter'; + export default DiscoverRouter; diff --git a/src/app/[variants]/(main)/discover/[[...path]]/page.tsx b/src/app/[variants]/(main)/discover/[[...path]]/page.tsx index a76c0ea673..81de7f2a92 100644 --- a/src/app/[variants]/(main)/discover/[[...path]]/page.tsx +++ b/src/app/[variants]/(main)/discover/[[...path]]/page.tsx @@ -1,11 +1,12 @@ 'use client'; -import DiscoverRouter from '../DiscoverRouter'; +import dynamic from 'next/dynamic'; -const DiscoverPage = () => { - return ; -}; +import { BrandTextLoading } from '@/components/Loading'; -DiscoverPage.displayName = 'DiscoverPage'; +const DiscoverRouter = dynamic(() => import('../DiscoverRouter'), { + loading: BrandTextLoading, + ssr: false, +}); -export default DiscoverPage; +export default DiscoverRouter; diff --git a/src/app/[variants]/(main)/discover/features/Search.tsx b/src/app/[variants]/(main)/discover/features/Search.tsx index ca33e1d1c6..444cade2c5 100644 --- a/src/app/[variants]/(main)/discover/features/Search.tsx +++ b/src/app/[variants]/(main)/discover/features/Search.tsx @@ -42,6 +42,7 @@ const Search = memo(() => { return ( { diff --git a/src/components/Loading/index.ts b/src/components/Loading/index.ts new file mode 100644 index 0000000000..6d8dfe60e9 --- /dev/null +++ b/src/components/Loading/index.ts @@ -0,0 +1 @@ +export { default as BrandTextLoading } from './BrandTextLoading';