mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
fix(frontend): support standalone demo APIs and runtime GitHub stars (#5302)
* fix(frontend): support standalone demo APIs and runtime GitHub stars * Update API origin URL to use environment variables Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test(frontend): align static demo tests with runtime origin --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
065f84f711
commit
dfaeef3772
@ -411,6 +411,14 @@ is opt-in: it fails fast when `frontend/.next` has no completed build.
|
||||
|
||||
Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx.
|
||||
|
||||
For a read-only demo without the Gateway, run `make build-static` from `frontend/`,
|
||||
then `HOSTNAME=127.0.0.1 PORT=3000 node --env-file=.env .next/standalone/server.js`
|
||||
from the same directory. The build includes public demo assets and resolves
|
||||
supported demo API reads locally; writes are unavailable. To display the homepage
|
||||
GitHub star count, set `GITHUB_OAUTH_TOKEN` in `frontend/.env` before starting Node.
|
||||
The token stays on the server; missing credentials or GitHub failures hide the
|
||||
count. Restart Node after changing the token; no rebuild is needed.
|
||||
|
||||
#### LangGraph Studio (Optional)
|
||||
|
||||
The default `make dev` topology uses DeerFlow's Gateway-embedded runtime and
|
||||
|
||||
@ -86,6 +86,16 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL=http://localhost:8001/api
|
||||
|
||||
Leave these unset for the standard `make dev` / Docker flow, where nginx serves the public `/api/langgraph/*` prefix and rewrites it to Gateway's native `/api/*` routes.
|
||||
|
||||
`make build-static` creates a standalone read-only demo and copies `.next/static`
|
||||
and `public` into the output. In static mode, `core/api/static-response.ts`
|
||||
resolves Gateway REST reads with empty capability/catalog responses or existing
|
||||
same-origin `/mock/api` fixtures; writes and unknown API routes fail locally.
|
||||
The homepage client counter calls `/github-stars`, outside the Gateway proxy.
|
||||
That dynamic route reads the server-only `GITHUB_OAUTH_TOKEN` at runtime, caches
|
||||
GitHub data for one hour, and returns 204 when the count is unavailable. Start
|
||||
the standalone server from `frontend/` with `node --env-file=.env
|
||||
.next/standalone/server.js` to load the current credentials.
|
||||
|
||||
To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in `DEER_FLOW_DEV_ALLOWED_ORIGINS` (comma-separated; a full URL is reduced to its host). It feeds Next's `allowedDevOrigins`, which gates `/_next/*`, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.
|
||||
|
||||
## Resources
|
||||
|
||||
@ -30,3 +30,4 @@ format:
|
||||
build-static:
|
||||
NEXT_CONFIG_BUILD_OUTPUT=standalone SKIP_ENV_VALIDATION=1 NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true $(PNPM) build
|
||||
@if [ -d .next/static ]; then mkdir -p .next/standalone/.next && cp -R .next/static .next/standalone/.next/static; fi
|
||||
@if [ -d public ]; then mkdir -p .next/standalone/public && cp -R public/. .next/standalone/public/; fi
|
||||
|
||||
42
frontend/src/app/github-stars/route.ts
Normal file
42
frontend/src/app/github-stars/route.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { env } from "@/env";
|
||||
|
||||
// Resolve deployment credentials on requests, never while prerendering a build.
|
||||
// The explicit fetch revalidate below still caches GitHub data for one hour.
|
||||
export const revalidate = 0;
|
||||
|
||||
/**
|
||||
* Return only the public star count using the server's runtime GitHub token.
|
||||
* No input; missing credentials, upstream failures, or invalid counts yield 204
|
||||
* so the header hides the counter. Stays outside nginx's /api Gateway proxy.
|
||||
*/
|
||||
export async function GET() {
|
||||
const token = env.GITHUB_OAUTH_TOKEN;
|
||||
const headers = { "Cache-Control": "no-store" };
|
||||
if (!token) return new Response(null, { status: 204, headers });
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/bytedance/deer-flow",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
next: { revalidate: 3600 },
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { stargazers_count?: unknown };
|
||||
if (
|
||||
typeof data.stargazers_count === "number" &&
|
||||
Number.isSafeInteger(data.stargazers_count) &&
|
||||
data.stargazers_count >= 0
|
||||
) {
|
||||
return Response.json({ stars: data.stargazers_count }, { headers });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The counter is optional; do not return upstream errors or credentials.
|
||||
}
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
@ -1,14 +1,14 @@
|
||||
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||
import { DEFAULT_LOCALE, type Locale } from "@/core/i18n/locale";
|
||||
import { getI18n } from "@/core/i18n/server";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
import { StarCounter } from "./star-counter";
|
||||
|
||||
export type HeaderProps = {
|
||||
className?: string;
|
||||
@ -72,8 +72,7 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
|
||||
>
|
||||
<GitHubLogoIcon className="size-4" />
|
||||
<span className="hidden sm:inline">Star on GitHub</span>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && <StarCounter />}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@ -87,39 +86,3 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
async function StarCounter() {
|
||||
let stars = 10000; // Default value
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/bytedance/deer-flow",
|
||||
{
|
||||
headers: env.GITHUB_OAUTH_TOKEN
|
||||
? {
|
||||
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
: {},
|
||||
next: {
|
||||
revalidate: 3600,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
stars = data.stargazers_count ?? stars; // Update stars if API response is valid
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching GitHub stars:", error);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
|
||||
{stars && (
|
||||
<NumberTicker className="font-mono tabular-nums" value={stars} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
45
frontend/src/components/landing/star-counter.tsx
Normal file
45
frontend/src/components/landing/star-counter.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { StarFilledIcon } from "@radix-ui/react-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||
|
||||
/**
|
||||
* Add the runtime star count to a prerendered header without a client-side token.
|
||||
* No props; hides the optional count while unavailable and cancels on unmount.
|
||||
*/
|
||||
export function StarCounter() {
|
||||
const [stars, setStars] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
// The root layout has no query provider; this one-shot decorative request
|
||||
// leaves homepage rendering static and GitHub caching on the server.
|
||||
void fetch("/github-stars", { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok || response.status === 204) return;
|
||||
const data = (await response.json()) as { stars?: unknown };
|
||||
if (
|
||||
!controller.signal.aborted &&
|
||||
typeof data.stars === "number" &&
|
||||
Number.isSafeInteger(data.stars) &&
|
||||
data.stars >= 0
|
||||
) {
|
||||
setStars(data.stars);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// An unavailable optional counter must not break the GitHub link.
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (stars === null) return null;
|
||||
return (
|
||||
<>
|
||||
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
|
||||
<NumberTicker className="font-mono tabular-nums" value={stars} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { buildLoginUrl } from "@/core/auth/types";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
|
||||
import { UnauthorizedError } from "./errors";
|
||||
import { staticApiResponse } from "./static-response";
|
||||
|
||||
/** HTTP methods that the gateway's CSRFMiddleware checks. */
|
||||
export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
@ -61,6 +63,17 @@ export async function fetch(
|
||||
): Promise<Response> {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
|
||||
// Static demos have no Gateway. Resolve REST calls before credentials,
|
||||
// CSRF, or redirects; demo assets and explicit mock routes still use HTTP.
|
||||
if (isStaticWebsiteOnly()) {
|
||||
const response = await staticApiResponse(url, {
|
||||
...init,
|
||||
method:
|
||||
init?.method ?? (typeof input === "string" ? "GET" : input.method),
|
||||
});
|
||||
if (response) return response;
|
||||
}
|
||||
|
||||
// Inject CSRF for state-changing methods. GET/HEAD/OPTIONS/TRACE skip
|
||||
// it to mirror the gateway's ``should_check_csrf`` logic exactly.
|
||||
let headers = init?.headers;
|
||||
|
||||
114
frontend/src/core/api/static-response.ts
Normal file
114
frontend/src/core/api/static-response.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { FeaturesResponse } from "@/core/features/api";
|
||||
import type { UserMemory } from "@/core/memory/types";
|
||||
|
||||
/**
|
||||
* Resolve Gateway REST calls for the read-only demo without contacting a backend.
|
||||
* Returns null for assets, mock routes, and unrelated origins so normal fetching
|
||||
* can continue. Settings reuse existing same-origin fixtures; unknown API routes
|
||||
* and writes fail locally rather than silently leaking out to a configured Gateway.
|
||||
*/
|
||||
export async function staticApiResponse(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response | null> {
|
||||
const origin =
|
||||
typeof window === "undefined"
|
||||
? `http://${process.env.HOSTNAME ?? "127.0.0.1"}:${process.env.PORT ?? "3000"}`
|
||||
: window.location.origin;
|
||||
const url = new URL(input, origin);
|
||||
const roots = [
|
||||
new URL(`${getBackendBaseURL()}/api/`, origin),
|
||||
new URL("/api/", origin),
|
||||
];
|
||||
const root = roots.find(
|
||||
(candidate) =>
|
||||
url.origin === candidate.origin &&
|
||||
url.pathname.startsWith(candidate.pathname),
|
||||
);
|
||||
if (!root) return null;
|
||||
|
||||
init?.signal?.throwIfAborted();
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
return Response.json(
|
||||
{ detail: "Unavailable in static demo mode" },
|
||||
{ status: 405 },
|
||||
);
|
||||
}
|
||||
|
||||
const path = url.pathname.slice(root.pathname.length).replace(/\/$/, "");
|
||||
// These routes already own the demo settings data; do not maintain a second copy.
|
||||
if (["skills", "mcp/config", "integrations/lark/status"].includes(path)) {
|
||||
return globalThis.fetch(new URL(`/mock/api/${path}`, origin).href, init);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
switch (path) {
|
||||
case "features":
|
||||
data = {
|
||||
agents_api: { enabled: false },
|
||||
browser_control: { enabled: false },
|
||||
mcp_tasks: { enabled: false },
|
||||
subagent_batches: {
|
||||
enabled: false,
|
||||
repository_available: false,
|
||||
worker_running: false,
|
||||
max_running: 0,
|
||||
},
|
||||
} satisfies FeaturesResponse;
|
||||
break;
|
||||
case "channels/providers":
|
||||
data = { enabled: false, providers: [] };
|
||||
break;
|
||||
case "channels/connections":
|
||||
data = { connections: [] };
|
||||
break;
|
||||
case "agents":
|
||||
data = { agents: [] };
|
||||
break;
|
||||
case "subagents":
|
||||
data = { subagents: [] };
|
||||
break;
|
||||
case "suggestions/config":
|
||||
data = { enabled: false, max_suggestions: 0 };
|
||||
break;
|
||||
case "memory":
|
||||
case "memory/export": {
|
||||
const empty = { summary: "", updatedAt: "" };
|
||||
data = {
|
||||
version: "1.0",
|
||||
lastUpdated: "",
|
||||
user: { workContext: empty, personalContext: empty, topOfMind: empty },
|
||||
history: {
|
||||
recentMonths: empty,
|
||||
earlierContext: empty,
|
||||
longTermBackground: empty,
|
||||
},
|
||||
facts: [],
|
||||
} satisfies UserMemory;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// The LangGraph static client already supplies the demo transcript.
|
||||
// There is no additional durable history or live token usage to fetch.
|
||||
if (/^threads\/[^/]+\/messages\/page$/.test(path)) {
|
||||
data = { data: [], has_more: false, next_before_seq: null };
|
||||
} else if (/^threads\/[^/]+\/token-usage$/.test(path)) {
|
||||
data = null;
|
||||
} else if (
|
||||
path === "scheduled-tasks" ||
|
||||
/^scheduled-tasks\/[^/]+\/runs$/.test(path) ||
|
||||
/^threads\/[^/]+\/scheduled-tasks$/.test(path) ||
|
||||
/^threads\/[^/]+\/runs\/[^/]+\/events$/.test(path)
|
||||
) {
|
||||
data = [];
|
||||
} else {
|
||||
return Response.json(
|
||||
{ detail: "Unavailable in static demo mode" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return method === "HEAD" ? new Response(null) : Response.json(data);
|
||||
}
|
||||
73
frontend/tests/unit/app/github-stars.test.ts
Normal file
73
frontend/tests/unit/app/github-stars.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
|
||||
import { GET, revalidate } from "@/app/github-stars/route";
|
||||
|
||||
const { env } = rs.hoisted(() => ({ env: { GITHUB_OAUTH_TOKEN: "" } }));
|
||||
rs.mock("@/env", () => ({ env }));
|
||||
|
||||
beforeEach(() => {
|
||||
env.GITHUB_OAUTH_TOKEN = "";
|
||||
});
|
||||
afterEach(() => {
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("runtime GitHub stars", () => {
|
||||
it("does not prerender or fetch when no runtime token is configured", async () => {
|
||||
const fetch = rs.spyOn(globalThis, "fetch");
|
||||
const response = await GET();
|
||||
expect(revalidate).toBe(0);
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the server token at request time and exposes only the count", async () => {
|
||||
env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
|
||||
const fetch = rs.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
Response.json({
|
||||
stargazers_count: 43210,
|
||||
private_data: "not-for-the-client",
|
||||
}),
|
||||
);
|
||||
const response = await GET();
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://api.github.com/repos/bytedance/deer-flow",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer runtime-test-token",
|
||||
}),
|
||||
next: { revalidate: 3600 },
|
||||
}),
|
||||
);
|
||||
expect(await response.json()).toEqual({ stars: 43210 });
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("hides the count when GitHub rejects the token", async () => {
|
||||
env.GITHUB_OAUTH_TOKEN = "invalid-test-token";
|
||||
rs.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(null, { status: 401 }),
|
||||
);
|
||||
expect((await GET()).status).toBe(204);
|
||||
});
|
||||
|
||||
it("hides the count on network failure without returning error details", async () => {
|
||||
env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
|
||||
rs.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new Error("private upstream details"),
|
||||
);
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(204);
|
||||
expect(await response.text()).toBe("");
|
||||
});
|
||||
|
||||
it.each([{}, { stargazers_count: -1 }, { stargazers_count: "81900" }])(
|
||||
"hides invalid GitHub data: %j",
|
||||
async (body) => {
|
||||
env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
|
||||
rs.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(body));
|
||||
expect((await GET()).status).toBe(204);
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
import { StarCounter } from "@/components/landing/star-counter";
|
||||
|
||||
rs.mock("@/components/ui/number-ticker", () => ({
|
||||
NumberTicker: ({ value }: { value: number }) => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("landing star counter", () => {
|
||||
it("loads the runtime count without sending a GitHub token from the browser", async () => {
|
||||
const fetch = rs
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(Response.json({ stars: 43210 }));
|
||||
render(<StarCounter />);
|
||||
await screen.findByText("43210");
|
||||
expect(fetch).toHaveBeenCalledWith("/github-stars", {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the count hidden when the runtime reports no available count", async () => {
|
||||
rs.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(null, { status: 204 }),
|
||||
);
|
||||
const { container } = render(<StarCounter />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the count hidden when the frontend endpoint is unreachable", async () => {
|
||||
rs.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new Error("Network unavailable"),
|
||||
);
|
||||
const { container } = render(<StarCounter />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("cancels the request when the header unmounts", () => {
|
||||
const fetch = rs.spyOn(globalThis, "fetch").mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Keep the request pending until the component cancels it.
|
||||
}),
|
||||
);
|
||||
const { unmount } = render(<StarCounter />);
|
||||
const signal = fetch.mock.calls[0]?.[1]?.signal;
|
||||
unmount();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
199
frontend/tests/unit/core/api/static-mode.test.ts
Normal file
199
frontend/tests/unit/core/api/static-mode.test.ts
Normal file
@ -0,0 +1,199 @@
|
||||
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 {
|
||||
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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user