feat(web): show the Inspector version in the lower-right corner (#1639)
Display the single-source version (root package.json) in the web UI's lower-right corner, in grey — matching the CLI and TUI. The browser can't read the filesystem, so the version flows backend → browser via GET /api/config: - Add `version?: string` to InitialConfigPayload; the web backend (webServerConfigToInitialPayload) reads it once via the shared readInspectorVersion() and tags it onto the payload. - Add a `useInspectorVersion` hook mirroring useSandboxUrl/useServerListWritable. - Add a presentational `VersionBadge` element rendered fixed lower-right, grey, non-interactive; styling lives in a `versionBadge` Text theme variant (no inline styles, --inspector-* tokens). - Wire it into App.tsx. Verified live: /api/config serves `version: "2.0.0"` and the badge renders `v2.0.0` fixed at the lower-right in grey. Tests: useInspectorVersion (full branch coverage), VersionBadge (unit + Storybook), web-server-config payload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
parent
9324f9f21c
commit
650e5bd020
2 changed files with 397 additions and 1 deletions
181
clients/web/src/test/core/react/useInspectorVersion.test.tsx
Normal file
181
clients/web/src/test/core/react/useInspectorVersion.test.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Tests for useInspectorVersion — runs in happy-dom under the unit project. Uses
|
||||
* a controlled fake `fetch` so each branch (present / absent / non-string / HTTP
|
||||
* error / network throw / post-unmount guards) and the auth header are asserted
|
||||
* directly. Mirrors useSandboxUrl's test (same `/api/config` fetch shape).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useInspectorVersion } from "@inspector/core/react/useInspectorVersion";
|
||||
|
||||
function jsonResponse(body: unknown, ok = true): Response {
|
||||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 401,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("useInspectorVersion", () => {
|
||||
it("starts loading, then resolves the version from the config payload", async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ version: "2.0.0" }));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.version).toBeUndefined();
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("sends the bearer auth header when a token is provided", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
||||
|
||||
renderHook(() =>
|
||||
useInspectorVersion({
|
||||
baseUrl: "http://test.local/",
|
||||
authToken: "secret-token",
|
||||
fetchFn,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchFn).toHaveBeenCalled());
|
||||
const [url, init] = fetchFn.mock.calls[0];
|
||||
// Trailing slash on baseUrl is normalized away.
|
||||
expect(url).toBe("http://test.local/api/config");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers["x-mcp-remote-auth"]).toBe("Bearer secret-token");
|
||||
});
|
||||
|
||||
it("omits the auth header when no token is provided", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
||||
|
||||
renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchFn).toHaveBeenCalled());
|
||||
const [, init] = fetchFn.mock.calls[0];
|
||||
expect(init.headers["x-mcp-remote-auth"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves version undefined when the payload omits it", async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ defaultEnvironment: {} }));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.version).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves version undefined when the field is not a usable string", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({ version: "" }));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.version).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves version undefined on a non-ok response", async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ version: "9.9.9" }, false));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.version).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves version undefined when the fetch throws", async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.version).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to globalThis.fetch when no fetchFn is provided", async () => {
|
||||
const globalFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ version: "3.1.4" }));
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = globalFetch as unknown as typeof fetch;
|
||||
try {
|
||||
const { result } = renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local" }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(globalFetch).toHaveBeenCalledWith(
|
||||
"http://test.local/api/config",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(result.current.version).toBe("3.1.4");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a response that resolves after unmount (no state update)", async () => {
|
||||
let resolveFetch: ((r: Response) => void) | undefined;
|
||||
const fetchFn = vi.fn().mockReturnValue(
|
||||
new Promise<Response>((r) => {
|
||||
resolveFetch = r;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
unmount();
|
||||
resolveFetch?.(jsonResponse({ version: "2.0.0" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(result.current.version).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops a response whose json resolves after unmount", async () => {
|
||||
let resolveJson: ((v: unknown) => void) | undefined;
|
||||
const res = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
new Promise((r) => {
|
||||
resolveJson = r;
|
||||
}),
|
||||
} as unknown as Response;
|
||||
const fetchFn = vi.fn().mockResolvedValue(res);
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useInspectorVersion({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(fetchFn).toHaveBeenCalled());
|
||||
await Promise.resolve();
|
||||
|
||||
unmount();
|
||||
resolveJson?.({ version: "2.0.0" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(result.current.version).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -260,6 +260,12 @@ describe("webServerConfigToInitialPayload", () => {
|
|||
expect(payload.defaultTransport).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tags the payload with the single-source Inspector version", () => {
|
||||
// Read from the root package.json via readInspectorVersion() at module load.
|
||||
const payload = webServerConfigToInitialPayload(baseConfig());
|
||||
expect(payload.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it("emits stdio defaults when initialMcpConfig.type === 'stdio'", () => {
|
||||
const cfg = baseConfig();
|
||||
cfg.initialMcpConfig = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue