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:
yangzheli
2026-04-18 08:21:08 +08:00
committed by GitHub
parent 898f4e8ac2
commit c6b0423558
16 changed files with 671 additions and 14 deletions
+46
View 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
View 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
View 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
View 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
View 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
View 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,
});
}