From dfaeef3772cd1af28155ab6406c659d2312d59dd Mon Sep 17 00:00:00 2001 From: JeffJiang Date: Wed, 9 Sep 2026 00:00:56 +0800 Subject: [PATCH] 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 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 8 + frontend/AGENTS.md | 10 + frontend/Makefile | 1 + frontend/src/app/github-stars/route.ts | 42 ++++ frontend/src/components/landing/header.tsx | 43 +--- .../src/components/landing/star-counter.tsx | 45 ++++ frontend/src/core/api/fetcher.ts | 13 ++ frontend/src/core/api/static-response.ts | 114 ++++++++++ frontend/tests/unit/app/github-stars.test.ts | 73 +++++++ .../landing/star-counter.dom.test.tsx | 61 ++++++ .../tests/unit/core/api/static-mode.test.ts | 199 ++++++++++++++++++ 11 files changed, 569 insertions(+), 40 deletions(-) create mode 100644 frontend/src/app/github-stars/route.ts create mode 100644 frontend/src/components/landing/star-counter.tsx create mode 100644 frontend/src/core/api/static-response.ts create mode 100644 frontend/tests/unit/app/github-stars.test.ts create mode 100644 frontend/tests/unit/components/landing/star-counter.dom.test.tsx create mode 100644 frontend/tests/unit/core/api/static-mode.test.ts diff --git a/README.md b/README.md index 384856423..ab567b5f3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 721c0ac99..3ef4c6bb7 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 diff --git a/frontend/Makefile b/frontend/Makefile index 862806bd5..2c1d8087c 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -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 diff --git a/frontend/src/app/github-stars/route.ts b/frontend/src/app/github-stars/route.ts new file mode 100644 index 000000000..d6815912d --- /dev/null +++ b/frontend/src/app/github-stars/route.ts @@ -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 }); +} diff --git a/frontend/src/components/landing/header.tsx b/frontend/src/components/landing/header.tsx index 992be510d..5de6076ff 100644 --- a/frontend/src/components/landing/header.tsx +++ b/frontend/src/components/landing/header.tsx @@ -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) { > Star on GitHub - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && - env.GITHUB_OAUTH_TOKEN && } + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && } @@ -87,39 +86,3 @@ export async function Header({ className, homeURL, locale }: HeaderProps) { ); } - -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 ( - <> - - {stars && ( - - )} - - ); -} diff --git a/frontend/src/components/landing/star-counter.tsx b/frontend/src/components/landing/star-counter.tsx new file mode 100644 index 000000000..dede352e9 --- /dev/null +++ b/frontend/src/components/landing/star-counter.tsx @@ -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(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 ( + <> + + + + ); +} diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts index dcb4e509a..ba7eb7f74 100644 --- a/frontend/src/core/api/fetcher.ts +++ b/frontend/src/core/api/fetcher.ts @@ -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 { 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; diff --git a/frontend/src/core/api/static-response.ts b/frontend/src/core/api/static-response.ts new file mode 100644 index 000000000..3680e046f --- /dev/null +++ b/frontend/src/core/api/static-response.ts @@ -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 { + 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); +} diff --git a/frontend/tests/unit/app/github-stars.test.ts b/frontend/tests/unit/app/github-stars.test.ts new file mode 100644 index 000000000..bb91494d6 --- /dev/null +++ b/frontend/tests/unit/app/github-stars.test.ts @@ -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); + }, + ); +}); diff --git a/frontend/tests/unit/components/landing/star-counter.dom.test.tsx b/frontend/tests/unit/components/landing/star-counter.dom.test.tsx new file mode 100644 index 000000000..f5197a1d1 --- /dev/null +++ b/frontend/tests/unit/components/landing/star-counter.dom.test.tsx @@ -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 }) => {value}, +})); + +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(); + 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(); + 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(); + 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(); + const signal = fetch.mock.calls[0]?.[1]?.signal; + unmount(); + expect(signal?.aborted).toBe(true); + }); +}); diff --git a/frontend/tests/unit/core/api/static-mode.test.ts b/frontend/tests/unit/core/api/static-mode.test.ts new file mode 100644 index 000000000..8fec6aaa8 --- /dev/null +++ b/frontend/tests/unit/core/api/static-mode.test.ts @@ -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" }), + ); + }); +});