mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-12 07:49:00 +00:00
* feat(gateway): add GET /api/features for frontend feature gating (#3757) * feat(agents): add useAgentsApiEnabled feature-flag hook (#3757) * feat(agents): gate /workspace/agents segment on agents_api flag (#3757) * feat(sidebar): grey out Agents button with tooltip when agents_api disabled (#3757) * fix(sidebar): show disabled Agents tooltip on hover, harden a11y + e2e (#3757) - Wrap the disabled Agents button in a hoverable tooltip trigger so the 'feature not enabled' hint shows in the expanded sidebar, not only when collapsed (the SidebarMenuButton tooltip prop is hidden unless collapsed). - Add tabIndex={-1} and drop the redundant onClick preventDefault. - Add e2e coverage for the disabled state + a default /api/features mock. * style(sidebar): move cursor-not-allowed to the hoverable span (#3757) * test(agents): anchor e2e agents-API request filter with a path regex (#3757) * fix(agents): don't expose backend config in disabled message; tell user to contact admin (#3757) The disabled panel previously said 'Set agents_api.enabled: true in config.yaml', leaking backend configuration to end users. Replace with a generic 'not enabled on this server, contact your administrator' message (matching the existing nameStepApiDisabledError copy). e2e now asserts the contact-admin message and that no config.yaml/agents_api text is rendered. * make format * fix(agents): keep agents_api flag sticky during /api/features outage (#3757) Failing open re-mounted the agents UI and re-triggered the 403 storm when agents_api was genuinely disabled and /api/features was down. Persist the last definitive answer and fall back to it (sticky) before failing open, only failing open when nothing has ever been observed. Read the cached value after mount so the first client render matches the server (no hydration mismatch on the non-loading-gated sidebar). * fix(sidebar): make disabled Agents entry keyboard/SR accessible (#3757) The disabled-state explanation was hover-only: tabIndex={-1} removed the entry from the tab order and the reason lived only in a pointer-triggered tooltip. Keep it in the tab order and wire aria-describedby to a visually-hidden reason so keyboard and screen-reader users learn why it is disabled. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
582 lines
17 KiB
TypeScript
582 lines
17 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";
|
|
|
|
const MOCK_AUTH_USER = {
|
|
id: "default",
|
|
email: "default@test.local",
|
|
system_role: "admin",
|
|
needs_setup: false,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type MockThread = {
|
|
thread_id: string;
|
|
title?: string;
|
|
updated_at?: string;
|
|
agent_name?: string;
|
|
metadata?: Record<string, unknown>;
|
|
messages?: unknown[];
|
|
artifacts?: string[];
|
|
};
|
|
|
|
export type MockAgent = {
|
|
name: string;
|
|
description?: string;
|
|
system_prompt?: string;
|
|
};
|
|
|
|
export type MockSkill = {
|
|
name: string;
|
|
description: string;
|
|
category?: string;
|
|
license?: string | null;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
export type MockAPIOptions = {
|
|
threads?: MockThread[];
|
|
agents?: MockAgent[];
|
|
skills?: MockSkill[];
|
|
uploadLimits?: {
|
|
max_files: number;
|
|
max_file_size: number;
|
|
max_total_size: number;
|
|
};
|
|
};
|
|
|
|
const DEFAULT_SKILLS: MockSkill[] = [
|
|
{
|
|
name: "data-analysis",
|
|
description: "Analyze structured data and produce charts.",
|
|
category: "public",
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: "frontend-design",
|
|
description: "Create polished frontend interfaces.",
|
|
category: "public",
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: "disabled-skill",
|
|
description: "Hidden from slash autocomplete.",
|
|
category: "public",
|
|
enabled: false,
|
|
},
|
|
];
|
|
|
|
function mockStreamMessages() {
|
|
return [
|
|
{
|
|
type: "human",
|
|
id: "msg-human-1",
|
|
content: [{ type: "text", text: "Hello" }],
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: "msg-ai-1",
|
|
content: "Hello from DeerFlow!",
|
|
},
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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) {
|
|
let threads = [...(options?.threads ?? [])];
|
|
const agents = options?.agents ?? [];
|
|
const skills = options?.skills ?? DEFAULT_SKILLS;
|
|
const uploadLimits = options?.uploadLimits ?? {
|
|
max_files: 10,
|
|
max_file_size: 50 * 1024 * 1024,
|
|
max_total_size: 100 * 1024 * 1024,
|
|
};
|
|
|
|
const upsertThread = (thread: MockThread) => {
|
|
threads = [
|
|
thread,
|
|
...threads.filter((existing) => existing.thread_id !== thread.thread_id),
|
|
];
|
|
};
|
|
|
|
const threadSearchResult = (thread: MockThread) => ({
|
|
thread_id: thread.thread_id,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: thread.updated_at ?? "2025-01-01T00:00:00Z",
|
|
metadata: {
|
|
...(thread.metadata ?? {}),
|
|
...(thread.agent_name ? { agent_name: thread.agent_name } : {}),
|
|
},
|
|
status: "idle",
|
|
values: { title: thread.title ?? "Untitled" },
|
|
});
|
|
|
|
// Auth — keep workspace tests independent from a real gateway session.
|
|
void page.route("**/api/v1/auth/me", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(MOCK_AUTH_USER),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route("**/api/v1/auth/setup-status", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ needs_setup: false }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route("**/api/v1/auth/logout", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({ status: 204 });
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route("**/api/channels/providers", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ enabled: false, providers: [] }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route("**/api/channels/connections", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ connections: [] }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Thread search — sidebar thread list & chats list page
|
|
void page.route("**/api/langgraph/threads/search", async (route) => {
|
|
const body = threads.map(threadSearchResult);
|
|
|
|
let limit: number | undefined;
|
|
let offset = 0;
|
|
try {
|
|
const postData = route.request().postDataJSON() as {
|
|
limit?: number;
|
|
offset?: number;
|
|
} | null;
|
|
if (postData) {
|
|
if (typeof postData.limit === "number") {
|
|
limit = postData.limit;
|
|
}
|
|
if (typeof postData.offset === "number") {
|
|
offset = postData.offset;
|
|
}
|
|
}
|
|
} catch {
|
|
// No / invalid JSON body — fall back to returning the full list.
|
|
}
|
|
|
|
const sliced =
|
|
typeof limit === "number" ? body.slice(offset, offset + limit) : body;
|
|
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(sliced),
|
|
});
|
|
});
|
|
|
|
// Thread create — called when user sends first message in a new chat
|
|
void page.route("**/api/langgraph/threads", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
upsertThread({
|
|
thread_id: MOCK_THREAD_ID,
|
|
title: "New Chat",
|
|
updated_at: new Date().toISOString(),
|
|
messages: mockStreamMessages(),
|
|
});
|
|
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) => {
|
|
const threadId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
|
);
|
|
const matchingThread = threads.find(
|
|
(thread) => thread.thread_id === threadId,
|
|
);
|
|
if (route.request().method() === "GET") {
|
|
if (!matchingThread) {
|
|
return route.fulfill({
|
|
status: 404,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ detail: "Thread not found" }),
|
|
});
|
|
}
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(threadSearchResult(matchingThread)),
|
|
});
|
|
}
|
|
if (route.request().method() === "PATCH") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
|
});
|
|
}
|
|
if (route.request().method() === "DELETE") {
|
|
threads = threads.filter((thread) => thread.thread_id !== threadId);
|
|
return route.fulfill({
|
|
status: 204,
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
|
if (route.request().method() === "DELETE") {
|
|
return route.fulfill({
|
|
status: 204,
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route("**/api/threads/*/uploads/limits", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(uploadLimits),
|
|
});
|
|
}
|
|
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: matchingThread.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}`,
|
|
},
|
|
],
|
|
artifacts: matchingThread.artifacts ?? [],
|
|
},
|
|
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
|
|
? (matchingThread.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}`,
|
|
},
|
|
])
|
|
: [],
|
|
artifacts: matchingThread?.artifacts ?? [],
|
|
},
|
|
next: [],
|
|
metadata: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// The URL carries a query string (e.g. `?limit=10&offset=0`), which Playwright
|
|
// glob `*` does NOT cross, so we match with a regex anchored to `/runs`
|
|
// followed by `?` or end-of-string. This must NOT match `/runs/stream`.
|
|
void page.route(/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/, (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(
|
|
matchingThread
|
|
? [
|
|
{
|
|
run_id: `run-${matchingThread.thread_id}`,
|
|
thread_id: matchingThread.thread_id,
|
|
assistant_id: "lead_agent",
|
|
status: "success",
|
|
metadata: {},
|
|
kwargs: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at:
|
|
matchingThread.updated_at ?? "2025-01-01T00:00:00Z",
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route(
|
|
/\/api\/threads\/([^/]+)\/runs\/([^/]+)\/messages/,
|
|
(route) => {
|
|
if (route.request().method() === "GET") {
|
|
const url = route.request().url();
|
|
const matchingThread = threads.find((t) =>
|
|
url.includes(`/api/threads/${t.thread_id}/runs/`),
|
|
);
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: (matchingThread?.messages ?? []).map((message, index) => ({
|
|
run_id: `run-${matchingThread?.thread_id ?? "unknown"}`,
|
|
content: message,
|
|
metadata: { caller: "lead_agent" },
|
|
created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`,
|
|
})),
|
|
hasMore: false,
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
},
|
|
);
|
|
|
|
// Run stream — returns a minimal SSE response with an AI message
|
|
const handleMockRunStream = (route: Route) => {
|
|
upsertThread({
|
|
thread_id: MOCK_THREAD_ID,
|
|
title: "New Chat",
|
|
updated_at: new Date().toISOString(),
|
|
messages: mockStreamMessages(),
|
|
});
|
|
return handleRunStream(route);
|
|
};
|
|
|
|
void page.route("**/api/langgraph/runs/stream", handleMockRunStream);
|
|
void page.route(
|
|
"**/api/langgraph/threads/*/runs/stream",
|
|
handleMockRunStream,
|
|
);
|
|
|
|
// Models list — model picker dropdown
|
|
void page.route("**/api/models", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
models: [],
|
|
token_usage: { enabled: false },
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Feature flags — frontend gates UI (e.g. agents) on these. Default to
|
|
// enabled so existing tests exercise the normal path; tests that need the
|
|
// disabled state override this route after calling mockLangGraphAPI.
|
|
void page.route("**/api/features", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ agents_api: { enabled: true } }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Skills list — settings page and slash autocomplete
|
|
void page.route("**/api/skills", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ skills }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Follow-up suggestions — input box auto-suggest after AI response
|
|
void page.route("**/api/threads/*/suggestions", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ suggestions: [] }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// 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: mockStreamMessages(),
|
|
},
|
|
},
|
|
{ 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,
|
|
});
|
|
}
|