mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
* 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>
262 lines
7.8 KiB
TypeScript
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,
|
|
});
|
|
}
|