Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions graph-ui/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("./lib/i18n")>();
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(<App />);

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(<App />);

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(<App />);

await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
});
});
24 changes: 24 additions & 0 deletions graph-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,24 @@ function routeUrl(tab: TabId, project: string | null): string {
export function App() {
const t = useUiMessages();
const [route, setRoute] = useState<RouteState>(readRoute);
const [version, setVersion] = useState<string | null>(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();
Expand Down Expand Up @@ -73,6 +89,14 @@ export function App() {
<span className="text-[13px] font-semibold text-foreground/90 tracking-tight">
Codebase Memory
</span>
{version && (
<span
className="translate-y-px text-[10px] font-mono text-foreground/30"
title="Server version"
>
{version.startsWith("v") ? version : `v${version}`}
</span>
)}
</div>

{/* Tabs inline in header */}
Expand Down
10 changes: 8 additions & 2 deletions src/ui/http_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 ─────────────────────────────────────────────── */
Expand Down
21 changes: 21 additions & 0 deletions tests/test_httpd.c
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
#ifndef CBM_VERSION
#define CBM_VERSION "dev"
#endif
#ifndef _WIN32
#include <sys/stat.h>
#endif
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
Loading