From c6b0423558cafb603b01148bbc999a0b883b315e Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:21:08 +0800 Subject: [PATCH] feat(frontend): add Playwright E2E tests with CI workflow (#2279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix: apply Copilot suggestions --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Willem Jiang --- .github/workflows/e2e-tests.yml | 63 ++++++ .gitignore | 2 + CONTRIBUTING.md | 7 +- frontend/AGENTS.md | 3 +- frontend/CLAUDE.md | 3 + frontend/Makefile | 3 + frontend/README.md | 8 + frontend/package.json | 2 + frontend/playwright.config.ts | 33 +++ frontend/pnpm-lock.yaml | 63 +++++- frontend/tests/e2e/agent-chat.spec.ts | 46 ++++ frontend/tests/e2e/chat.spec.ts | 51 +++++ frontend/tests/e2e/landing.spec.ts | 32 +++ frontend/tests/e2e/sidebar.spec.ts | 32 +++ frontend/tests/e2e/thread-history.spec.ts | 76 +++++++ frontend/tests/e2e/utils/mock-api.ts | 261 ++++++++++++++++++++++ 16 files changed, 671 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/e2e-tests.yml create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/tests/e2e/agent-chat.spec.ts create mode 100644 frontend/tests/e2e/chat.spec.ts create mode 100644 frontend/tests/e2e/landing.spec.ts create mode 100644 frontend/tests/e2e/sidebar.spec.ts create mode 100644 frontend/tests/e2e/thread-history.spec.ts create mode 100644 frontend/tests/e2e/utils/mock-api.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 000000000..8f5379ed1 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index ed37fecbe..4e46d2e71 100644 --- a/.gitignore +++ b/.gitignore @@ -55,5 +55,7 @@ web/ backend/Dockerfile.langgraph config.yaml.bak .playwright-mcp +/frontend/test-results/ +/frontend/playwright-report/ .gstack/ .worktrees diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca7b05a33..241ca71af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index f2bdba8f3..036927a2b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a6dbfcf03..1f66bf98e 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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 ``` diff --git a/frontend/Makefile b/frontend/Makefile index c2b49861c..48d23b97b 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -10,6 +10,9 @@ dev: test: pnpm test +test-e2e: + pnpm test:e2e + lint: pnpm lint diff --git a/frontend/README.md b/frontend/README.md index 6ed73babf..6db881301 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 | diff --git a/frontend/package.json b/frontend/package.json index 587be4e3f..198dba37b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..2673564b6 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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", + }, + }, +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 366030210..0d5fe8d88 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: diff --git a/frontend/tests/e2e/agent-chat.spec.ts b/frontend/tests/e2e/agent-chat.spec.ts new file mode 100644 index 000000000..35616705b --- /dev/null +++ b/frontend/tests/e2e/agent-chat.spec.ts @@ -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 }); + }); +}); diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts new file mode 100644 index 000000000..490305de9 --- /dev/null +++ b/frontend/tests/e2e/chat.spec.ts @@ -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, + }); + }); +}); diff --git a/frontend/tests/e2e/landing.spec.ts b/frontend/tests/e2e/landing.spec.ts new file mode 100644 index 000000000..b9d59f918 --- /dev/null +++ b/frontend/tests/e2e/landing.spec.ts @@ -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/); + }); +}); diff --git a/frontend/tests/e2e/sidebar.spec.ts b/frontend/tests/e2e/sidebar.spec.ts new file mode 100644 index 000000000..4aac9fa58 --- /dev/null +++ b/frontend/tests/e2e/sidebar.spec.ts @@ -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 + 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/); + }); +}); diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts new file mode 100644 index 000000000..19fce310a --- /dev/null +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -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(); + }); +}); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts new file mode 100644 index 000000000..ca4862c09 --- /dev/null +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -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, + }); +}