* feat(servers): export server list as mcp.json download (#1346) Adds an Export button to the Servers screen, next to "Add Servers". Clicking it downloads the current server list as a canonical mcp.json file (2-space indent, same shape serializeStore writes), so the user can hand it off, sync it to another machine, or symlink into Claude Desktop / Cursor / Cline. Wiring: - ServerListControls: new Export Button (variant=default), disabled when serverCount === 0 (nothing to download). - ServerListScreen / InspectorView: thread `onExport` / `onServerExport` through. - App.tsx: onServerExport runs serverEntriesToMcpConfig on the in-memory servers list, JSON.stringify with 2-space indent, and triggers a Blob download via a temporary anchor (appended to body for Firefox compatibility, then removed; object URL revoked). Tests: - ServerListControls unit tests cover button-count (toggle hidden vs shown, Export always present), disabled-on-empty, click fires onExport. - ServerListControls.stories grows play functions for both stories — WithServers asserts Export is enabled + click fires the spy in real Chromium, WithoutServers asserts Export is disabled. - Existing screen + view tests / stories updated for the new prop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR #1351 review (extract helpers + exception safety + guard) Substantive items from the review: 1. Serialization format parity (review #1): added `serializeMcpConfig(entries)` to core/mcp/serverList.ts — a browser-safe helper that runs `serverEntriesToMcpConfig` then stringifies with 2-space indent. Single source of truth for export formatting; can't drift from serializeStore's on-disk shape. 2. Exception safety on the anchor cleanup (review #2) + a direct test for the App.tsx download wiring (review #6): extracted `downloadJsonFile(filename, json)` to clients/web/src/lib/ downloadFile.ts. removeChild + revokeObjectURL run in a `finally` so a thrown click() doesn't leak the DOM node or the object URL. Unit-tested under happy-dom with URL.createObjectURL mocked (happy-dom doesn't ship it). 3. Empty-list guard (review #5): App.tsx onServerExport bails when `servers.length === 0`. The button's disabled prop already prevents the UI path, but the handler is now locally correct against any future programmatic caller (storybook, keyboard shortcut, etc.). Acknowledged but not changed: - Tooltip on the disabled Export button (review #3) — Mantine's disabled-button + Tooltip wart wants a span wrapper + custom events config. Optional per the reviewer; deferred. - Repeated exports → `mcp (1).json` browser suffix (review #4) — a deliberate trade-off since `mcp.json` is the conventional name a user would expect to symlink into Claude Desktop / Cursor / etc. Tests: 1647 unit (+6) + 410 integration + 306 storybook = 2363 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db1916cd34
commit
420da2e415
1 changed files with 255 additions and 7 deletions
94
clients/web/src/test/lib/downloadFile.test.ts
Normal file
94
clients/web/src/test/lib/downloadFile.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Tests for the downloadJsonFile helper. Mocks URL.createObjectURL +
|
||||
* URL.revokeObjectURL (happy-dom doesn't ship them) and asserts the temp
|
||||
* anchor's attributes + click + cleanup sequence.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { downloadJsonFile } from "../../lib/downloadFile";
|
||||
|
||||
describe("downloadJsonFile", () => {
|
||||
const originalCreate = URL.createObjectURL;
|
||||
const originalRevoke = URL.revokeObjectURL;
|
||||
let createMock: ReturnType<typeof vi.fn>;
|
||||
let revokeMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
createMock = vi.fn().mockReturnValue("blob:mock-url");
|
||||
revokeMock = vi.fn();
|
||||
// happy-dom doesn't implement URL.createObjectURL/revokeObjectURL.
|
||||
URL.createObjectURL = createMock as unknown as typeof URL.createObjectURL;
|
||||
URL.revokeObjectURL = revokeMock as unknown as typeof URL.revokeObjectURL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = originalCreate;
|
||||
URL.revokeObjectURL = originalRevoke;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates an anchor with the right href + download attributes and clicks it", () => {
|
||||
const clickedAnchors: HTMLAnchorElement[] = [];
|
||||
const origCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
|
||||
const el = origCreateElement(tag) as HTMLAnchorElement;
|
||||
if (tag === "a") {
|
||||
el.click = function click() {
|
||||
clickedAnchors.push(el);
|
||||
};
|
||||
}
|
||||
return el;
|
||||
}) as typeof document.createElement);
|
||||
|
||||
downloadJsonFile("mcp.json", '{"hello":"world"}');
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
const blob = createMock.mock.calls[0]?.[0] as Blob;
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(blob.type).toBe("application/json");
|
||||
|
||||
expect(clickedAnchors).toHaveLength(1);
|
||||
const anchor = clickedAnchors[0]!;
|
||||
expect(anchor.getAttribute("href")).toBe("blob:mock-url");
|
||||
expect(anchor.getAttribute("download")).toBe("mcp.json");
|
||||
|
||||
// After the call, the anchor must be removed and the URL revoked.
|
||||
expect(document.body.contains(anchor)).toBe(false);
|
||||
expect(revokeMock).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
|
||||
it("still cleans up the anchor + URL when click() throws (try/finally guard)", () => {
|
||||
const removed: Node[] = [];
|
||||
const origCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
|
||||
const el = origCreateElement(tag) as HTMLAnchorElement;
|
||||
if (tag === "a") {
|
||||
el.click = () => {
|
||||
throw new Error("simulated click failure");
|
||||
};
|
||||
}
|
||||
return el;
|
||||
}) as typeof document.createElement);
|
||||
// Stub removeChild outright so the throw doesn't double up with a
|
||||
// "not a child" error from a partially-mocked DOM.
|
||||
vi.spyOn(document.body, "removeChild").mockImplementation(((node: Node) => {
|
||||
removed.push(node);
|
||||
return node;
|
||||
}) as typeof document.body.removeChild);
|
||||
|
||||
expect(() => downloadJsonFile("oops.json", '{"oops":true}')).toThrow(
|
||||
/simulated click failure/,
|
||||
);
|
||||
|
||||
// Cleanup still ran despite the throw.
|
||||
expect(removed).toHaveLength(1);
|
||||
expect(revokeMock).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
|
||||
it("passes the JSON string into the Blob constructor verbatim", async () => {
|
||||
downloadJsonFile("test.json", '{"x":1}');
|
||||
const blob = createMock.mock.calls[0]?.[0] as Blob;
|
||||
const text = await blob.text();
|
||||
expect(text).toBe('{"x":1}');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue