diff --git a/graph-ui/src/App.test.tsx b/graph-ui/src/App.test.tsx new file mode 100644 index 000000000..f6437c40d --- /dev/null +++ b/graph-ui/src/App.test.tsx @@ -0,0 +1,59 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { messages } from "./lib/i18n"; + +vi.mock("./components/GraphTab", () => ({ GraphTab: () => null })); +vi.mock("./components/StatsTab", () => ({ StatsTab: () => null })); +vi.mock("./components/ControlTab", () => ({ ControlTab: () => null })); +vi.mock("./lib/i18n", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useUiMessages: () => messages.en }; +}); + +describe("App", () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); + }); + + it("shows the serving binary version", async () => { + vi.stubGlobal("fetch", vi.fn(async () => + new Response(JSON.stringify({ lang: "en", version: "0.10.8" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + )); + + render(); + + expect(await screen.findByText("v0.10.8")).toBeVisible(); + }); + + it("hides the version when the config has no string version", async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ lang: "en", version: 108 }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config")); + expect(screen.queryByTitle("Server version")).not.toBeInTheDocument(); + }); + + it("hides the version when the config request fails", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("offline"); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config")); + expect(screen.queryByTitle("Server version")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 18392667c..a0d84b0b6 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -33,8 +33,24 @@ function routeUrl(tab: TabId, project: string | null): string { export function App() { const t = useUiMessages(); const [route, setRoute] = useState(readRoute); + const [version, setVersion] = useState(null); const { tab: activeTab, project: selectedProject } = route; + useEffect(() => { + let cancelled = false; + void fetch("/api/ui-config") + .then((response) => (response.ok ? response.json() : null)) + .then((config) => { + if (!cancelled && typeof config?.version === "string" && config.version) { + setVersion(config.version); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + /* Normalize the URL on first load so it always carries the current route. */ useEffect(() => { const initial = readRoute(); @@ -73,6 +89,14 @@ export function App() { Codebase Memory + {version && ( + + {version.startsWith("v") ? version : `v${version}`} + + )} {/* Tabs inline in header */} diff --git a/src/ui/http_server.c b/src/ui/http_server.c index b187438a5..77a600b11 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -66,6 +66,10 @@ /* ── Constants ────────────────────────────────────────────────── */ +#ifndef CBM_VERSION +#define CBM_VERSION "dev" +#endif + /* Max JSON-RPC request body size (1 MB) — transport enforces the same cap. */ #define MAX_BODY_SIZE CBM_HTTP_MAX_BODY @@ -143,8 +147,10 @@ static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) { * audit forbids hardcoded external URLs in graph-ui source (external * targets must come from an auditable backend response, same pattern as * the /api/repo-info deep-links). */ - cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\",\"upstream_issues_url\":\"%s\"}", - lang_buf, "https://github.com/DeusData/codebase-memory-mcp/issues/new"); + cbm_http_replyf(c, 200, g_cors_json, + "{\"lang\":\"%s\",\"version\":\"%s\",\"upstream_issues_url\":\"%s\"}", + lang_buf, CBM_VERSION, + "https://github.com/DeusData/codebase-memory-mcp/issues/new"); } /* ── Server state ─────────────────────────────────────────────── */ diff --git a/tests/test_httpd.c b/tests/test_httpd.c index eacf92634..3ac680197 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -33,6 +33,9 @@ #include #include #include +#ifndef CBM_VERSION +#define CBM_VERSION "dev" +#endif #ifndef _WIN32 #include #endif @@ -1573,6 +1576,23 @@ TEST(ui_server_ui_config_detects_zh_accept_language) { PASS(); } +TEST(ui_server_ui_config_includes_serving_version_issue1820) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + + char resp[4096]; + int n = th_http(cbm_http_server_port(ts.srv), "GET /api/ui-config HTTP/1.1\r\n\r\n", resp, + sizeof(resp)); + ASSERT_TRUE(n > 0); + ASSERT_EQ(th_status(resp), 200); + char expected_version[128]; + snprintf(expected_version, sizeof(expected_version), "\"version\":\"%s\"", CBM_VERSION); + ASSERT_NOT_NULL(strstr(resp, expected_version)); + + th_server_stop(&ts); + PASS(); +} + TEST(ui_server_ui_config_prefers_config_lang) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX"); @@ -2397,6 +2417,7 @@ SUITE(httpd) { RUN_TEST(ui_server_delete_project_invalid_name_keeps_watch); RUN_TEST(ui_server_delete_project_unlink_failure_keeps_watch); RUN_TEST(ui_server_ui_config_detects_zh_accept_language); + RUN_TEST(ui_server_ui_config_includes_serving_version_issue1820); RUN_TEST(ui_server_ui_config_prefers_config_lang); RUN_TEST(ui_server_slow_request_hits_deadline); RUN_TEST(ui_server_access_log_redacts_query);