inspector/clients/web/src/test/lib/downloadFile.test.ts
cliffhall ace4250d31 refactor(web): rename "History" tab/feature to "Protocol" (#1623)
Rename the web client's History tab and its supporting code to Protocol
so the feature reads as the MCP "Protocol" view.

- Tab renders as "Protocol"; INSPECTOR_TAB_IDS, InspectorView tab
  constants/MonitorTab, TAB_UI_REGISTRY key, and navigation.ts tab id all
  updated to Protocol.
- Rename components + folders: HistoryScreen -> ProtocolScreen,
  HistoryListPanel -> ProtocolListPanel, HistoryEntry -> ProtocolEntry,
  HistoryControls -> ProtocolControls, historyUtils -> protocolUtils
  (plus their tests and stories, and exported types).
- Rename App/InspectorView plumbing: historyUi, pinnedHistoryIds,
  on*History* handlers/props, replayHistoryRequest,
  isReplayableHistoryMethod, sort/compact scopes, etc. -> Protocol names.
- Export filenames use the "protocol" slug (buildExportFilename +
  ExportKind).

Preserved (per issue #1623 decisions):
- The in-panel section discriminator keeps its "pinned" | "history"
  values, and the sub-sections stay labeled Pinned / History.
- window.history and core message-log/replay concepts are untouched.

npm run ci passes (format, lint, build, unit tests, >=90 per-file
coverage gate, smoke, Storybook).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
2026-07-09 19:04:54 -04:00

187 lines
6.8 KiB
TypeScript

/**
* Tests for the download helpers. Mocks URL.createObjectURL +
* URL.revokeObjectURL (happy-dom doesn't ship them) and asserts the temp
* anchor's attributes + click + deferred cleanup sequence.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
downloadBlob,
downloadJsonFile,
buildExportFilename,
fileNameFromUri,
isHttpUrl,
} from "../../lib/downloadFile";
describe("downloadBlob / downloadJsonFile", () => {
const originalCreate = URL.createObjectURL;
const originalRevoke = URL.revokeObjectURL;
let createMock: ReturnType<typeof vi.fn>;
let revokeMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
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(() => {
vi.useRealTimers();
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);
// Revoke is deferred so the scheduled download can read the blob first.
expect(revokeMock).not.toHaveBeenCalled();
vi.runAllTimers();
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);
vi.runAllTimers();
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}');
});
it("downloadBlob passes the given blob through and honours its type", async () => {
const blob = new Blob(["plain text"], { type: "text/plain" });
downloadBlob("note.txt", blob);
expect(createMock).toHaveBeenCalledWith(blob);
const passed = createMock.mock.calls[0]?.[0] as Blob;
expect(passed.type).toBe("text/plain");
expect(await passed.text()).toBe("plain text");
});
});
describe("buildExportFilename", () => {
const fixedNow = new Date("2026-03-17T10:00:42.123Z");
it("includes kind, server id, and ISO timestamp with `:` swapped for `-`", () => {
expect(buildExportFilename("protocol", "alpha", fixedNow)).toBe(
"inspector-protocol-alpha-2026-03-17T10-00-42.123Z.json",
);
});
it("omits the server-id segment when serverId is undefined", () => {
expect(buildExportFilename("logs", undefined, fixedNow)).toBe(
"inspector-logs-2026-03-17T10-00-42.123Z.json",
);
});
it("omits the server-id segment when serverId is an empty string", () => {
expect(buildExportFilename("logs", "", fixedNow)).toBe(
"inspector-logs-2026-03-17T10-00-42.123Z.json",
);
});
it("encodes server ids that contain filesystem-unsafe characters", () => {
expect(buildExportFilename("network", "my server/v2", fixedNow)).toBe(
"inspector-network-my%20server%2Fv2-2026-03-17T10-00-42.123Z.json",
);
});
it("defaults `now` to the current time when not provided", () => {
const name = buildExportFilename("protocol", "alpha");
expect(name).toMatch(
/^inspector-protocol-alpha-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z\.json$/,
);
});
});
describe("fileNameFromUri", () => {
it("returns the last path segment", () => {
expect(fileNameFromUri("https://x/y/z/report.pdf")).toBe("report.pdf");
});
it("splits on backslashes as well as forward slashes", () => {
expect(fileNameFromUri("C:\\Users\\me\\report.pdf")).toBe("report.pdf");
});
it("strips control/format chars and disallowed filename chars", () => {
// U+200B (zero-width space, Cf) and U+202E (RTL override, Cf) are
// stripped; the disallowed `*?` run collapses to a single `_`.
const uri = "https://x/a\u200bb\u202ec*?.txt";
expect(fileNameFromUri(uri)).toBe("abc_.txt");
});
it("truncates very long names to 255 characters", () => {
const long = "a".repeat(300) + ".txt";
expect(fileNameFromUri(`https://x/${long}`)).toHaveLength(255);
});
it("falls back to 'download' when nothing usable remains", () => {
expect(fileNameFromUri("https://x/")).toBe("download");
});
});
describe("isHttpUrl", () => {
it("accepts http and https", () => {
expect(isHttpUrl("https://example.com")?.href).toBe("https://example.com/");
expect(isHttpUrl("http://localhost:3000/a")?.href).toBe(
"http://localhost:3000/a",
);
});
it("rejects other schemes and unparsable input", () => {
expect(isHttpUrl("javascript:alert(1)")).toBeNull();
expect(isHttpUrl("data:text/html,<b>")).toBeNull();
expect(isHttpUrl("file:///etc/passwd")).toBeNull();
expect(isHttpUrl("not a url")).toBeNull();
});
});