yangzheli c6b0423558
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>
2026-04-18 08:21:08 +08:00

262 lines
7.8 KiB
TypeScript

/**
* 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,
});
}