mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 03:08:09 +00:00
feat(frontend): add Playwright E2E tests with CI workflow (#2279)
* feat(frontend): add Playwright E2E tests with CI workflow
Add end-to-end testing infrastructure using Playwright (Chromium only).
14 tests across 5 spec files cover landing page, chat workspace,
thread history, sidebar navigation, and agent chat — all with mocked
LangGraph/Backend APIs via network interception (zero backend dependency).
New files:
- playwright.config.ts — Chromium, 30s timeout, auto-start Next.js
- tests/e2e/utils/mock-api.ts — shared API mocks & SSE stream helpers
- tests/e2e/{landing,chat,thread-history,sidebar,agent-chat}.spec.ts
- .github/workflows/e2e-tests.yml — push main + PR trigger, paths filter
Updated: package.json, Makefile, .gitignore, CONTRIBUTING.md,
frontend/CLAUDE.md, frontend/AGENTS.md, frontend/README.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: apply Copilot suggestions
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
898f4e8ac2
commit
c6b0423558
63
.github/workflows/e2e-tests.yml
vendored
Normal file
63
.github/workflows/e2e-tests.yml
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main' ]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/e2e-tests.yml'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/e2e-tests.yml'
|
||||
|
||||
concurrency:
|
||||
group: e2e-tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Use pinned pnpm version
|
||||
run: corepack prepare pnpm@10.26.2 --activate
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: frontend
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: frontend
|
||||
run: pnpm exec playwright test
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: '1'
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -55,5 +55,7 @@ web/
|
||||
backend/Dockerfile.langgraph
|
||||
config.yaml.bak
|
||||
.playwright-mcp
|
||||
/frontend/test-results/
|
||||
/frontend/playwright-report/
|
||||
.gstack/
|
||||
.worktrees
|
||||
|
||||
@ -300,9 +300,13 @@ Nginx (port 2026) ← Unified entry point
|
||||
cd backend
|
||||
make test
|
||||
|
||||
# Frontend tests
|
||||
# Frontend unit tests
|
||||
cd frontend
|
||||
make test
|
||||
|
||||
# Frontend E2E tests (requires Chromium; builds and auto-starts the Next.js production server)
|
||||
cd frontend
|
||||
make test-e2e
|
||||
```
|
||||
|
||||
### PR Regression Checks
|
||||
@ -311,6 +315,7 @@ Every pull request triggers the following CI workflows:
|
||||
|
||||
- **Backend unit tests** — [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml)
|
||||
- **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml)
|
||||
- **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change)
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ DeerFlow is built on a sophisticated agent-based architecture using the [LangGra
|
||||
|
||||
```
|
||||
tests/
|
||||
├── e2e/ # E2E tests (Playwright, Chromium, mocked backend)
|
||||
└── unit/ # Unit tests (mirrors src/ layout, powered by Vitest)
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
@ -98,7 +99,7 @@ When adding new agent features:
|
||||
1. Follow the established project structure
|
||||
2. Add comprehensive TypeScript types
|
||||
3. Implement proper error handling
|
||||
4. Write unit tests under `tests/unit/` (run with `pnpm test`)
|
||||
4. Write unit tests under `tests/unit/` (run with `pnpm test`) and E2E tests under `tests/e2e/` (run with `pnpm test:e2e`)
|
||||
5. Update this documentation
|
||||
6. Follow the code style guide (ESLint + Prettier)
|
||||
|
||||
|
||||
@ -18,11 +18,14 @@ DeerFlow Frontend is a Next.js 16 web interface for an AI agent system. It commu
|
||||
| `pnpm lint` | ESLint only |
|
||||
| `pnpm lint:fix` | ESLint with auto-fix |
|
||||
| `pnpm test` | Run unit tests with Vitest |
|
||||
| `pnpm test:e2e` | Run E2E tests with Playwright (Chromium) |
|
||||
| `pnpm typecheck` | TypeScript type check (`tsc --noEmit`) |
|
||||
| `pnpm start` | Start production server |
|
||||
|
||||
Unit tests live under `tests/unit/` and mirror the `src/` layout (e.g., `tests/unit/core/api/stream-mode.test.ts` tests `src/core/api/stream-mode.ts`). Powered by Vitest; import source modules via the `@/` path alias.
|
||||
|
||||
E2E tests live under `tests/e2e/` and use Playwright with Chromium. They mock all backend APIs via `page.route()` network interception and test real page interactions (navigation, chat input, streaming responses). Config: `playwright.config.ts`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@ -10,6 +10,9 @@ dev:
|
||||
test:
|
||||
pnpm test
|
||||
|
||||
test-e2e:
|
||||
pnpm test:e2e
|
||||
|
||||
lint:
|
||||
pnpm lint
|
||||
|
||||
|
||||
@ -53,6 +53,12 @@ pnpm lint
|
||||
# Run unit tests
|
||||
pnpm test
|
||||
|
||||
# One-time setup: install Playwright Chromium browser
|
||||
pnpm exec playwright install chromium
|
||||
|
||||
# Run E2E tests (builds and starts production server automatically)
|
||||
pnpm test:e2e
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
@ -86,6 +92,7 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL="http://localhost:2024"
|
||||
|
||||
```
|
||||
tests/
|
||||
├── e2e/ # E2E tests (Playwright, Chromium, mocked backend)
|
||||
└── unit/ # Unit tests (mirrors src/ layout)
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
@ -125,6 +132,7 @@ src/
|
||||
| `pnpm build` | Build for production |
|
||||
| `pnpm start` | Start production server |
|
||||
| `pnpm test` | Run unit tests with Vitest |
|
||||
| `pnpm test:e2e` | Run E2E tests with Playwright |
|
||||
| `pnpm format` | Check formatting with Prettier |
|
||||
| `pnpm format:write` | Apply formatting with Prettier |
|
||||
| `pnpm lint` | Run ESLint |
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -93,6 +94,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
|
||||
33
frontend/playwright.config.ts
Normal file
33
frontend/playwright.config.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? "github" : "html",
|
||||
timeout: 30_000,
|
||||
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: "pnpm build && pnpm start",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
SKIP_ENV_VALIDATION: "1",
|
||||
},
|
||||
},
|
||||
});
|
||||
63
frontend/pnpm-lock.yaml
generated
63
frontend/pnpm-lock.yaml
generated
@ -115,7 +115,7 @@ importers:
|
||||
version: 1.2.1
|
||||
better-auth:
|
||||
specifier: ^1.3
|
||||
version: 1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3))
|
||||
version: 1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3))
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@ -160,16 +160,16 @@ importers:
|
||||
version: 5.1.6
|
||||
next:
|
||||
specifier: ^16.1.7
|
||||
version: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
nextra:
|
||||
specifier: ^4.6.1
|
||||
version: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
version: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
nextra-theme-docs:
|
||||
specifier: ^4.6.1
|
||||
version: 4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
version: 4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
nuxt-og-image:
|
||||
specifier: ^5.1.13
|
||||
version: 5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3))
|
||||
@ -228,6 +228,9 @@ importers:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.3
|
||||
'@playwright/test':
|
||||
specifier: ^1.59.1
|
||||
version: 1.59.1
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.0.15
|
||||
version: 4.1.18
|
||||
@ -1156,6 +1159,11 @@ packages:
|
||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
@ -3632,6 +3640,11 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@ -4794,6 +4807,16 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright-core@1.59.1:
|
||||
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.59.1:
|
||||
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
points-on-curve@0.2.0:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
@ -6847,6 +6870,10 @@ snapshots:
|
||||
|
||||
'@opentelemetry/api@1.9.0': {}
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
dependencies:
|
||||
playwright: 1.59.1
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
@ -8415,7 +8442,7 @@ snapshots:
|
||||
|
||||
best-effort-json-parser@1.2.1: {}
|
||||
|
||||
better-auth@1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)):
|
||||
better-auth@1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
@ -8430,7 +8457,7 @@ snapshots:
|
||||
nanostores: 1.1.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))
|
||||
@ -9475,6 +9502,9 @@ snapshots:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@ -10724,7 +10754,7 @@ snapshots:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.7
|
||||
'@swc/helpers': 0.5.15
|
||||
@ -10744,18 +10774,19 @@ snapshots:
|
||||
'@next/swc-win32-arm64-msvc': 16.1.7
|
||||
'@next/swc-win32-x64-msvc': 16.1.7
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@playwright/test': 1.59.1
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
dependencies:
|
||||
'@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
clsx: 2.1.1
|
||||
next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
nextra: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
nextra: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
react: 19.2.4
|
||||
react-compiler-runtime: 19.1.0-rc.3(react@19.2.4)
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
@ -10767,7 +10798,7 @@ snapshots:
|
||||
- immer
|
||||
- use-sync-external-store
|
||||
|
||||
nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
|
||||
nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.6.2
|
||||
'@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -10788,7 +10819,7 @@ snapshots:
|
||||
mdast-util-gfm: 3.1.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
negotiator: 1.0.0
|
||||
next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-compiler-runtime: 19.1.0-rc.3(react@19.2.4)
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
@ -11096,6 +11127,14 @@ snapshots:
|
||||
|
||||
playwright-core@1.58.2: {}
|
||||
|
||||
playwright-core@1.59.1: {}
|
||||
|
||||
playwright@1.59.1:
|
||||
dependencies:
|
||||
playwright-core: 1.59.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-path@0.2.1:
|
||||
|
||||
46
frontend/tests/e2e/agent-chat.spec.ts
Normal file
46
frontend/tests/e2e/agent-chat.spec.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
const MOCK_AGENTS = [
|
||||
{
|
||||
name: "test-agent",
|
||||
description: "A test agent for E2E tests",
|
||||
system_prompt: "You are a test agent.",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Agent chat", () => {
|
||||
test("agent gallery page loads and shows agents", async ({ page }) => {
|
||||
mockLangGraphAPI(page, { agents: MOCK_AGENTS });
|
||||
|
||||
await page.goto("/workspace/agents");
|
||||
|
||||
// The agent card should appear with the agent name
|
||||
await expect(page.getByText("test-agent")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("agent chat page loads with input box", async ({ page }) => {
|
||||
mockLangGraphAPI(page, { agents: MOCK_AGENTS });
|
||||
|
||||
await page.goto("/workspace/agents/test-agent/chats/new");
|
||||
|
||||
// The prompt input textarea should be visible
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("agent chat page shows agent badge", async ({ page }) => {
|
||||
mockLangGraphAPI(page, { agents: MOCK_AGENTS });
|
||||
|
||||
await page.goto("/workspace/agents/test-agent/chats/new");
|
||||
|
||||
// The agent badge should display in the header (scoped to header to avoid
|
||||
// matching the welcome area which also shows the agent name)
|
||||
await expect(
|
||||
page.locator("header span", { hasText: "test-agent" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
51
frontend/tests/e2e/chat.spec.ts
Normal file
51
frontend/tests/e2e/chat.spec.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
test.describe("Chat workspace", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
mockLangGraphAPI(page);
|
||||
});
|
||||
|
||||
test("new chat page loads with input box", async ({ page }) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("can type a message in the input box", async ({ page }) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await textarea.fill("Hello, DeerFlow!");
|
||||
await expect(textarea).toHaveValue("Hello, DeerFlow!");
|
||||
});
|
||||
|
||||
test("sending a message triggers API call and shows response", async ({
|
||||
page,
|
||||
}) => {
|
||||
let streamCalled = false;
|
||||
await page.route("**/runs/stream", (route) => {
|
||||
streamCalled = true;
|
||||
return handleRunStream(route);
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await textarea.fill("Hello");
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy();
|
||||
|
||||
// The AI response should appear in the chat
|
||||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
32
frontend/tests/e2e/landing.spec.ts
Normal file
32
frontend/tests/e2e/landing.spec.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
test.describe("Landing page", () => {
|
||||
test("renders the header and hero section", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Header brand name
|
||||
await expect(
|
||||
page.locator("header h1", { hasText: "DeerFlow" }),
|
||||
).toBeVisible();
|
||||
|
||||
// "Get Started" call-to-action button in hero
|
||||
await expect(
|
||||
page.getByRole("link", { name: /get started/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Get Started link navigates to workspace", async ({ page }) => {
|
||||
mockLangGraphAPI(page);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
const getStarted = page.getByRole("link", { name: /get started/i });
|
||||
await getStarted.click();
|
||||
|
||||
// Should redirect to /workspace/chats/new
|
||||
await page.waitForURL("**/workspace/chats/new");
|
||||
await expect(page).toHaveURL(/\/workspace\/chats\/new/);
|
||||
});
|
||||
});
|
||||
32
frontend/tests/e2e/sidebar.spec.ts
Normal file
32
frontend/tests/e2e/sidebar.spec.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
test.describe("Sidebar navigation", () => {
|
||||
test("sidebar contains Chats and Agents nav links", async ({ page }) => {
|
||||
mockLangGraphAPI(page);
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
// Sidebar uses data-sidebar="menu-button" with asChild rendering on <Link>
|
||||
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||||
await expect(sidebar.locator("a[href='/workspace/chats']")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(sidebar.locator("a[href='/workspace/agents']")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Agents link navigates to agents page", async ({ page }) => {
|
||||
mockLangGraphAPI(page);
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||||
const agentsLink = sidebar.locator("a[href='/workspace/agents']");
|
||||
await expect(agentsLink).toBeVisible({ timeout: 15_000 });
|
||||
await agentsLink.click();
|
||||
|
||||
await page.waitForURL("**/workspace/agents");
|
||||
await expect(page).toHaveURL(/\/workspace\/agents/);
|
||||
});
|
||||
});
|
||||
76
frontend/tests/e2e/thread-history.spec.ts
Normal file
76
frontend/tests/e2e/thread-history.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
mockLangGraphAPI,
|
||||
MOCK_THREAD_ID,
|
||||
MOCK_THREAD_ID_2,
|
||||
} from "./utils/mock-api";
|
||||
|
||||
const THREADS = [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "First conversation",
|
||||
updated_at: "2025-06-01T12:00:00Z",
|
||||
},
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID_2,
|
||||
title: "Second conversation",
|
||||
updated_at: "2025-06-02T12:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Thread history", () => {
|
||||
test("sidebar shows existing threads", async ({ page }) => {
|
||||
mockLangGraphAPI(page, { threads: THREADS });
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
// Both thread titles should appear in the sidebar
|
||||
await expect(page.getByText("First conversation")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText("Second conversation")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking a thread in sidebar navigates to it", async ({ page }) => {
|
||||
mockLangGraphAPI(page, { threads: THREADS });
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
// Wait for sidebar to populate
|
||||
const firstThread = page.getByText("First conversation");
|
||||
await expect(firstThread).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Click on the first thread
|
||||
await firstThread.click();
|
||||
|
||||
// Should navigate to that thread's URL
|
||||
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
|
||||
});
|
||||
|
||||
test("existing thread loads historical messages", async ({ page }) => {
|
||||
mockLangGraphAPI(page, { threads: THREADS });
|
||||
|
||||
// Navigate directly to an existing thread
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
|
||||
// The historical AI response should be displayed
|
||||
await expect(
|
||||
page.getByText("Response in thread First conversation"),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("chats list page shows all threads", async ({ page }) => {
|
||||
mockLangGraphAPI(page, { threads: THREADS });
|
||||
|
||||
await page.goto("/workspace/chats");
|
||||
|
||||
// Both threads should be listed in the main content area
|
||||
const main = page.locator("main");
|
||||
await expect(main.getByText("First conversation")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(main.getByText("Second conversation")).toBeVisible();
|
||||
});
|
||||
});
|
||||
261
frontend/tests/e2e/utils/mock-api.ts
Normal file
261
frontend/tests/e2e/utils/mock-api.ts
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Shared mock helpers for E2E tests.
|
||||
*
|
||||
* Intercepts all LangGraph / Backend API endpoints so tests can run without
|
||||
* a real backend. Each test file imports `mockLangGraphAPI` and
|
||||
* `handleRunStream` from here.
|
||||
*/
|
||||
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants — deterministic IDs used across tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001";
|
||||
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
||||
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MockThread = {
|
||||
thread_id: string;
|
||||
title?: string;
|
||||
updated_at?: string;
|
||||
agent_name?: string;
|
||||
};
|
||||
|
||||
export type MockAgent = {
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt?: string;
|
||||
};
|
||||
|
||||
export type MockAPIOptions = {
|
||||
threads?: MockThread[];
|
||||
agents?: MockAgent[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mockLangGraphAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mock all LangGraph API endpoints that the frontend calls on page load and
|
||||
* during message sending. Without these mocks the pages would hang waiting
|
||||
* for a real backend.
|
||||
*/
|
||||
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
const threads = options?.threads ?? [];
|
||||
const agents = options?.agents ?? [];
|
||||
|
||||
// Thread search — sidebar thread list & chats list page
|
||||
void page.route("**/api/langgraph/threads/search", (route) => {
|
||||
const body = threads.map((t) => ({
|
||||
thread_id: t.thread_id,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
|
||||
metadata: t.agent_name ? { agent_name: t.agent_name } : {},
|
||||
status: "idle",
|
||||
values: { title: t.title ?? "Untitled" },
|
||||
}));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
|
||||
// Thread create — called when user sends first message in a new chat
|
||||
void page.route("**/api/langgraph/threads", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
metadata: {},
|
||||
status: "idle",
|
||||
values: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Thread update (PATCH) — metadata update after creation
|
||||
void page.route("**/api/langgraph/threads/*", (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Thread history — useStream fetches state history on mount
|
||||
void page.route("**/api/langgraph/threads/*/history", (route) => {
|
||||
const url = route.request().url();
|
||||
|
||||
// For threads that exist in our mock data, return history with messages
|
||||
const matchingThread = threads.find((t) => url.includes(t.thread_id));
|
||||
if (matchingThread) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
values: {
|
||||
title: matchingThread.title ?? "Untitled",
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
id: `msg-human-${matchingThread.thread_id}`,
|
||||
content: [{ type: "text", text: "Previous question" }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: `msg-ai-${matchingThread.thread_id}`,
|
||||
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
next: [],
|
||||
metadata: {},
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
parent_config: null,
|
||||
},
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
// New threads — empty history
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "[]",
|
||||
});
|
||||
});
|
||||
|
||||
// Thread state — getState for individual thread
|
||||
void page.route("**/api/langgraph/threads/*/state", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
const url = route.request().url();
|
||||
const matchingThread = threads.find((t) => url.includes(t.thread_id));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
values: {
|
||||
title: matchingThread?.title ?? "Untitled",
|
||||
messages: matchingThread
|
||||
? [
|
||||
{
|
||||
type: "human",
|
||||
id: `msg-human-${matchingThread.thread_id}`,
|
||||
content: [{ type: "text", text: "Previous question" }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: `msg-ai-${matchingThread.thread_id}`,
|
||||
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
next: [],
|
||||
metadata: {},
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
}),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Run stream — returns a minimal SSE response with an AI message
|
||||
void page.route("**/api/langgraph/runs/stream", handleRunStream);
|
||||
void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream);
|
||||
|
||||
// Agents list — sidebar & gallery page
|
||||
void page.route("**/api/agents", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ agents }),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Individual agent — agent chat page
|
||||
void page.route("**/api/agents/*", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
const url = route.request().url();
|
||||
const agent = agents.find((a) => url.endsWith(`/api/agents/${a.name}`));
|
||||
if (agent) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(agent),
|
||||
});
|
||||
}
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Agent not found" }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleRunStream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a minimal SSE stream that the LangGraph SDK can parse.
|
||||
* The stream returns a single AI message: "Hello from DeerFlow!".
|
||||
*/
|
||||
export function handleRunStream(route: Route) {
|
||||
const events = [
|
||||
{
|
||||
event: "metadata",
|
||||
data: { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID },
|
||||
},
|
||||
{
|
||||
event: "values",
|
||||
data: {
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-1",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-1",
|
||||
content: "Hello from DeerFlow!",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ event: "end", data: {} },
|
||||
];
|
||||
|
||||
const body = events
|
||||
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
|
||||
.join("");
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body,
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user