mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-23 08:25:57 +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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user