mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-23 04:56:18 +00:00
* 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
322 lines
11 KiB
TypeScript
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();
|
|
}
|
|
});
|