mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✅ test: add BDD test framework and initial tests with Playwright and Cucumber (#9843)
* 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 <arvinxx@users.noreply.github.com> * 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 <arvinxx@users.noreply.github.com>
This commit is contained in:
26
.github/PULL_REQUEST_TEMPLATE.md
vendored
26
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -12,10 +12,36 @@
|
||||
- [ ] 📝 docs
|
||||
- [ ] 🔨 chore
|
||||
|
||||
#### 🔗 Related Issue
|
||||
|
||||
<!-- Link to the issue that is fixed by this PR -->
|
||||
|
||||
<!-- Example: Fixes #123, Closes #456, Related to #789 -->
|
||||
|
||||
#### 🔀 Description of Change
|
||||
|
||||
<!-- Thank you for your Pull Request. Please provide a description above. -->
|
||||
|
||||
#### 🧪 How to Test
|
||||
|
||||
<!-- Please describe how you tested your changes -->
|
||||
|
||||
<!-- For AI features, please include test prompts or scenarios -->
|
||||
|
||||
- [ ] Tested locally
|
||||
- [ ] Added/updated tests
|
||||
- [ ] No tests needed
|
||||
|
||||
#### 📸 Screenshots / Videos
|
||||
|
||||
<!-- If this PR includes UI changes, please provide screenshots or videos -->
|
||||
|
||||
| Before | After |
|
||||
| ------ | ----- |
|
||||
| ... | ... |
|
||||
|
||||
#### 📝 Additional Information
|
||||
|
||||
<!-- Add any other context about the Pull Request here. -->
|
||||
|
||||
<!-- Breaking changes? Migration guide? Performance impact? -->
|
||||
|
||||
12
.github/workflows/e2e.yml
vendored
12
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
143
e2e/README.md
Normal file
143
e2e/README.md
Normal file
@@ -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
|
||||
```
|
||||
20
e2e/cucumber.config.js
Normal file
20
e2e/cucumber.config.js
Normal file
@@ -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,
|
||||
};
|
||||
24
e2e/package.json
Normal file
24
e2e/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string>([
|
||||
'/image',
|
||||
'/changelog',
|
||||
'/settings?active=common',
|
||||
'/settings?active=llm',
|
||||
]);
|
||||
|
||||
// @ts-ignore
|
||||
async function assertNoPageErrors(page: Parameters<typeof test>[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);
|
||||
});
|
||||
}
|
||||
});
|
||||
11
e2e/src/features/discover/smoke.feature
Normal file
11
e2e/src/features/discover/smoke.feature
Normal file
@@ -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
|
||||
43
e2e/src/features/routes/core-routes.feature
Normal file
43
e2e/src/features/routes/core-routes.feature
Normal file
@@ -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 "<route>"
|
||||
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=<tab>"
|
||||
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 |
|
||||
36
e2e/src/steps/common/navigation.steps.ts
Normal file
36
e2e/src/steps/common/navigation.steps.ts
Normal file
@@ -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();
|
||||
});
|
||||
34
e2e/src/steps/discover/smoke.steps.ts
Normal file
34
e2e/src/steps/discover/smoke.steps.ts
Normal file
@@ -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);
|
||||
});
|
||||
69
e2e/src/steps/hooks.ts
Normal file
69
e2e/src/steps/hooks.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
41
e2e/src/steps/routes/routes.steps.ts
Normal file
41
e2e/src/steps/routes/routes.steps.ts
Normal file
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
96
e2e/src/support/webServer.ts
Normal file
96
e2e/src/support/webServer.ts
Normal file
@@ -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<void> | null = null;
|
||||
|
||||
interface WebServerOptions {
|
||||
command: string;
|
||||
env?: Record<string, string>;
|
||||
port: number;
|
||||
reuseExistingServer?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
async function isServerRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/chat`, {
|
||||
method: 'HEAD',
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWebServer(options: WebServerOptions): Promise<void> {
|
||||
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<void> {
|
||||
if (serverProcess) {
|
||||
console.log('🛑 Stopping web server...');
|
||||
serverProcess.kill();
|
||||
serverProcess = null;
|
||||
serverStartPromise = null;
|
||||
}
|
||||
}
|
||||
76
e2e/src/support/world.ts
Normal file
76
e2e/src/support/world.ts
Normal file
@@ -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<Buffer> {
|
||||
console.log(name);
|
||||
return await this.page.screenshot({ fullPage: true });
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(CustomWorld);
|
||||
19
e2e/tsconfig.json
Normal file
19
e2e/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
@@ -26,7 +26,8 @@
|
||||
"author": "LobeHub <i@lobehub.com>",
|
||||
"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",
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
packages:
|
||||
- 'packages/**'
|
||||
- '.'
|
||||
- 'e2e'
|
||||
- '!apps/**'
|
||||
|
||||
@@ -69,6 +69,7 @@ const AssistantItem = memo<DiscoverAssistantItem>(
|
||||
return (
|
||||
<Block
|
||||
clickable
|
||||
data-testid="assistant-item"
|
||||
height={'100%'}
|
||||
onClick={() => {
|
||||
navigate(link);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import DiscoverRouter from '../DiscoverRouter';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const DiscoverPage = () => {
|
||||
return <DiscoverRouter />;
|
||||
};
|
||||
import { BrandTextLoading } from '@/components/Loading';
|
||||
|
||||
DiscoverPage.displayName = 'DiscoverPage';
|
||||
const DiscoverRouter = dynamic(() => import('../DiscoverRouter'), {
|
||||
loading: BrandTextLoading,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default DiscoverPage;
|
||||
export default DiscoverRouter;
|
||||
|
||||
@@ -42,6 +42,7 @@ const Search = memo<StoreSearchBarProps>(() => {
|
||||
|
||||
return (
|
||||
<SearchBar
|
||||
data-testid="search-bar"
|
||||
defaultValue={q}
|
||||
enableShortKey
|
||||
onInputChange={(v) => {
|
||||
|
||||
1
src/components/Loading/index.ts
Normal file
1
src/components/Loading/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BrandTextLoading } from './BrandTextLoading';
|
||||
Reference in New Issue
Block a user