deer-flow/frontend/tests/unit/core/api/static-mode.test.ts
Wenchao An 42334f26d7
feat(capabilities): unify catalog, plugin configuration and agent selection (#5497)
* feat(capabilities): unify catalog, plugin configuration and agent selection

* fix(capabilities): address review isolation, validation and demo issues

* fix(capabilities): preserve concurrent selections and guide launcher repair
2026-09-19 12:14:24 +08:00

322 lines
11 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { listAgents } from "@/core/agents/api";
import { fetch as apiFetch } from "@/core/api/fetcher";
import { staticCapabilityCatalog } from "@/core/capabilities/static";
import {
listChannelConnections,
listChannelProviders,
} from "@/core/channels/api";
import { fetchFeatures } from "@/core/features/api";
import { loadLarkIntegrationStatus } from "@/core/integrations/lark/api";
import { loadMCPConfig } from "@/core/mcp/api";
import { loadMemory } from "@/core/memory/api";
import {
createScheduledTask,
fetchScheduledTaskRuns,
fetchScheduledTasks,
fetchThreadScheduledTasks,
} from "@/core/scheduled-tasks/api";
import { loadSkills } from "@/core/skills/api";
import { listSubagents } from "@/core/subagents/api";
import { loadSuggestionsConfig } from "@/core/suggestions/api";
import { fetchSubtaskSteps } from "@/core/tasks/api";
import { fetchThreadTokenUsage } from "@/core/threads/api";
const { env } = rs.hoisted(() => ({
env: {
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "true",
NEXT_PUBLIC_BACKEND_BASE_URL: "",
},
}));
rs.mock("@/env", () => ({ env }));
const network = rs.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Response.json({}),
);
beforeEach(() => {
rs.stubEnv("HOSTNAME", undefined);
rs.stubEnv("PORT", undefined);
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY = "true";
env.NEXT_PUBLIC_BACKEND_BASE_URL = "";
network.mockReset();
network.mockResolvedValue(Response.json({}));
rs.stubGlobal("fetch", network);
});
afterEach(() => {
rs.unstubAllEnvs();
rs.unstubAllGlobals();
});
describe("static website API requests", () => {
it("loads optional capabilities and empty catalogs without the Gateway", async () => {
await expect(fetchFeatures()).resolves.toMatchObject({
agents_api: { enabled: false },
browser_control: { enabled: false },
mcp_tasks: { enabled: false },
subagent_batches: { repository_available: false, worker_running: false },
});
await expect(listChannelProviders()).resolves.toEqual({
enabled: false,
providers: [],
});
await expect(listChannelConnections()).resolves.toEqual([]);
await expect(listAgents()).resolves.toEqual([]);
await expect(listSubagents()).resolves.toEqual([]);
await expect(loadSuggestionsConfig()).resolves.toMatchObject({
enabled: false,
});
await expect(fetchScheduledTasks()).resolves.toEqual([]);
await expect(fetchThreadScheduledTasks("demo / thread")).resolves.toEqual(
[],
);
await expect(fetchScheduledTaskRuns("task / id")).resolves.toEqual([]);
await expect(fetchSubtaskSteps("thread", "run", "task")).resolves.toEqual(
[],
);
await expect(fetchThreadTokenUsage("demo")).resolves.toBeNull();
const history = await apiFetch("/api/threads/demo/messages/page?limit=50");
expect(await history.json()).toEqual({
data: [],
has_more: false,
next_before_seq: null,
});
await expect(loadMemory()).resolves.toMatchObject({
facts: [],
user: {},
history: {},
});
expect(network).not.toHaveBeenCalled();
});
it("uses the existing same-origin settings fixtures even with a configured Gateway", async () => {
env.NEXT_PUBLIC_BACKEND_BASE_URL = "https://gateway.example/prefix";
network.mockResolvedValueOnce(Response.json({ skills: [] }));
await expect(loadSkills()).resolves.toEqual([]);
network.mockResolvedValueOnce(Response.json({ mcp_servers: {} }));
await expect(loadMCPConfig()).resolves.toEqual({ mcp_servers: {} });
network.mockResolvedValueOnce(Response.json({ installed: false }));
await expect(loadLarkIntegrationStatus()).resolves.toMatchObject({
installed: false,
});
expect(network.mock.calls.map(([url]) => url)).toEqual([
"http://127.0.0.1:3000/mock/api/skills",
"http://127.0.0.1:3000/mock/api/mcp/config",
"http://127.0.0.1:3000/mock/api/integrations/lark/status",
]);
await expect(fetchFeatures()).resolves.toMatchObject({
agents_api: { enabled: false },
});
expect(network).toHaveBeenCalledTimes(3);
});
it("rejects writes and unsupported endpoints locally instead of reporting fake success", async () => {
await expect(
createScheduledTask({
context_mode: "fresh_thread_per_run",
title: "Demo",
prompt: "Demo",
schedule_type: "once",
schedule_spec: {},
timezone: "UTC",
}),
).rejects.toThrow("Unavailable in static demo mode");
expect((await apiFetch("/api/new-feature")).status).toBe(404);
expect(
(
await apiFetch(
new Request("http://127.0.0.1:3000/api/subagents", {
method: "DELETE",
}),
)
).status,
).toBe(405);
expect(network).not.toHaveBeenCalled();
});
it.each([
["demo.internal", undefined, "http://demo.internal:3000"],
[undefined, "4000", "http://127.0.0.1:4000"],
["demo.internal", "4000", "http://demo.internal:4000"],
])(
"uses the server origin with HOSTNAME=%s and PORT=%s",
async (hostname, port, origin) => {
rs.stubEnv("HOSTNAME", hostname);
rs.stubEnv("PORT", port);
await apiFetch("/api/skills");
expect(network).toHaveBeenCalledWith(
`${origin}/mock/api/skills`,
expect.anything(),
);
network.mockClear();
const response = await apiFetch(
new Request(`${origin}/api/subagents`, { method: "DELETE" }),
);
expect(response.status).toBe(405);
expect(network).not.toHaveBeenCalled();
},
);
it("leaves demo assets and unrelated external requests intact", async () => {
await apiFetch("/demo/threads/demo/thread.json");
await apiFetch("https://external.example/api/features");
expect(network).toHaveBeenCalledTimes(2);
});
it("uses the current browser origin for fixtures and respects cancellation", async () => {
rs.stubEnv("HOSTNAME", "demo.internal");
rs.stubEnv("PORT", "4000");
rs.stubGlobal("window", { location: { origin: "http://127.0.0.1:3000" } });
await apiFetch("/api/skills");
expect(network).toHaveBeenCalledWith(
"http://127.0.0.1:3000/mock/api/skills",
expect.anything(),
);
network.mockClear();
const controller = new AbortController();
controller.abort();
await expect(
apiFetch("/api/features", { signal: controller.signal }),
).rejects.toThrow();
expect(
await (await apiFetch("/api/features", { method: "HEAD" })).text(),
).toBe("");
expect(network).not.toHaveBeenCalled();
});
it("preserves normal backend requests unless the flag is exactly true", async () => {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY = "false";
network.mockResolvedValue(Response.json({ agents_api: { enabled: true } }));
await expect(fetchFeatures()).resolves.toMatchObject({
agents_api: { enabled: true },
});
expect(network).toHaveBeenCalledWith(
"/api/features",
expect.objectContaining({ credentials: "include" }),
);
});
});
it("serves the canonical capability catalog locally and rejects writes", async () => {
const response = await apiFetch("/api/capabilities/catalog");
expect(response.status).toBe(200);
const catalog = (await response.json()) as { id: string }[];
expect(catalog).toEqual(
JSON.parse(
readFileSync(
path.resolve(
process.cwd(),
"../backend/packages/harness/deerflow/capabilities/builtin.json",
),
"utf8",
),
),
);
expect(network).not.toHaveBeenCalled();
const write = await apiFetch("/api/capabilities/installations", {
method: "POST",
body: "{}",
});
expect(write.status).toBe(405);
expect(network).not.toHaveBeenCalled();
});
it("projects demo installations without copying secrets or calling the Gateway", async () => {
env.NEXT_PUBLIC_BACKEND_BASE_URL = "https://gateway.example/prefix";
network.mockResolvedValueOnce(
Response.json({
mcp_servers: {
github: {
enabled: true,
env: { TOKEN: "private" },
url: "https://private.example",
capability: { plugin_id: "github" },
},
},
}),
);
const response = await apiFetch(
"https://gateway.example/prefix/api/capabilities/installations/mcp",
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toMatchObject({
can_manage: false,
items: [
{ name: "github", plugin_id: "github", adapter: "mcp", installed: true },
],
});
expect(JSON.stringify(data)).not.toContain("private");
expect(network.mock.calls[0]?.[0]).toContain("/mock/api/mcp/config");
});
it("provides Lark, skills and business projections from the owning fixtures", async () => {
for (const [adapter, fixture, count] of [
["lark", { installed: false }, 1],
[
"skills",
{
skills: [
{
name: "research",
category: "public",
enabled: true,
description: "Research",
},
],
},
1,
],
["business", { mcp_servers: {} }, 0],
] as const) {
network.mockResolvedValueOnce(Response.json(fixture));
const response = await apiFetch(
`/api/capabilities/installations/${adapter}`,
);
expect(response.status).toBe(200);
const data = (await response.json()) as {
items: unknown[];
can_manage: boolean;
};
expect(data.items).toHaveLength(count);
expect(data.can_manage).toBe(false);
}
const unknown = await apiFetch("/api/capabilities/installations/unknown");
expect(unknown.status).toBe(404);
});
it("discovers newly cataloged business adapters without a provider allowlist", async () => {
const template = staticCapabilityCatalog.find(
(plugin) => plugin.adapter === "business",
)!;
staticCapabilityCatalog.push({ ...template, id: "future-business" });
try {
network.mockResolvedValueOnce(
Response.json({
mcp_servers: {
future: {
enabled: true,
capability: { plugin_id: "future-business" },
},
github: { enabled: true, capability: { plugin_id: "github" } },
unknown: { enabled: true, capability: { plugin_id: "unknown" } },
},
}),
);
const response = await apiFetch("/api/capabilities/installations/business");
const result = await response.json();
expect(result).toMatchObject({
can_manage: false,
items: [{ name: "future", plugin_id: "future-business" }],
});
expect(result.items).toHaveLength(1);
} finally {
staticCapabilityCatalog.pop();
}
});