* 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
13 changed files with 255 additions and 7 deletions
|
|
@ -21,6 +21,7 @@ import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResource
|
|||
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
|
||||
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
|
||||
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
||||
import { serializeMcpConfig } from "@inspector/core/mcp/serverList.js";
|
||||
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
|
||||
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
||||
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
|
||||
|
|
@ -45,6 +46,7 @@ import {
|
|||
type ServerConfigModalMode,
|
||||
} from "./components/groups/ServerConfigModal/ServerConfigModal";
|
||||
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
|
||||
import { downloadJsonFile } from "./lib/downloadFile";
|
||||
import { createWebEnvironment } from "./lib/environmentFactory";
|
||||
|
||||
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
|
||||
|
|
@ -605,6 +607,18 @@ function App() {
|
|||
/* TODO: not wired yet */
|
||||
}, []);
|
||||
|
||||
// Download the current server list as a canonical mcp.json file. Uses the
|
||||
// in-memory `servers` list (kept in sync with disk by useServers' refresh-
|
||||
// after-mutate flow) so there's no extra HTTP roundtrip. Serialization
|
||||
// format (2-space indent) lives in serializeMcpConfig so the export
|
||||
// matches what serializeStore writes on the backend. The button is
|
||||
// disabled when the list is empty, but the guard here keeps the handler
|
||||
// locally correct against any future programmatic caller.
|
||||
const onServerExport = useCallback(() => {
|
||||
if (servers.length === 0) return;
|
||||
downloadJsonFile("mcp.json", serializeMcpConfig(servers));
|
||||
}, [servers]);
|
||||
|
||||
// Remove handler — runs after the user confirms in the modal. When removing
|
||||
// the active server, also tear down the session in-place so the client and
|
||||
// its 9 state managers can be GC'd now instead of lingering until the next
|
||||
|
|
@ -740,6 +754,7 @@ function App() {
|
|||
onServerAdd={() => setConfigModal({ mode: "add" })}
|
||||
onServerImportConfig={todoNoop}
|
||||
onServerImportJson={todoNoop}
|
||||
onServerExport={onServerExport}
|
||||
onServerInfo={todoNoop}
|
||||
onServerSettings={todoNoop}
|
||||
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { ServerListControls } from "./ServerListControls";
|
||||
|
||||
const meta: Meta<typeof ServerListControls> = {
|
||||
|
|
@ -18,6 +18,17 @@ export const WithServers: Story = {
|
|||
onAddManually: fn(),
|
||||
onImportConfig: fn(),
|
||||
onImportServerJson: fn(),
|
||||
onExport: fn(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
// Real-Chromium regression guard: Export is enabled when servers exist,
|
||||
// and clicking it fires onExport. Unit tests cover the same path under
|
||||
// happy-dom; this catches anything browser-specific in the wiring.
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const exportBtn = await body.findByRole("button", { name: /Export/ });
|
||||
await expect(exportBtn).not.toBeDisabled();
|
||||
await userEvent.click(exportBtn);
|
||||
await expect(args.onExport).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -29,5 +40,11 @@ export const WithoutServers: Story = {
|
|||
onAddManually: fn(),
|
||||
onImportConfig: fn(),
|
||||
onImportServerJson: fn(),
|
||||
onExport: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const exportBtn = await body.findByRole("button", { name: /Export/ });
|
||||
await expect(exportBtn).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,19 +10,23 @@ const baseProps = {
|
|||
onAddManually: vi.fn(),
|
||||
onImportConfig: vi.fn(),
|
||||
onImportServerJson: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ServerListControls", () => {
|
||||
it("hides the list toggle when there are no servers", () => {
|
||||
it("hides the list toggle when there are no servers (Export + Add Servers remain)", () => {
|
||||
renderWithMantine(<ServerListControls {...baseProps} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]).toHaveAccessibleName(/Add Servers/);
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(screen.getByRole("button", { name: /Export/ })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Add Servers/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the list toggle when servers exist", () => {
|
||||
it("shows the list toggle alongside Export + Add Servers when servers exist", () => {
|
||||
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("calls onToggleList when the list toggle is clicked", async () => {
|
||||
|
|
@ -39,4 +43,24 @@ describe("ServerListControls", () => {
|
|||
await user.click(buttons[0]);
|
||||
expect(onToggleList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables Export when the list is empty (nothing to download)", () => {
|
||||
renderWithMantine(<ServerListControls {...baseProps} />);
|
||||
expect(screen.getByRole("button", { name: /Export/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Export when at least one server exists", () => {
|
||||
renderWithMantine(<ServerListControls {...baseProps} serverCount={1} />);
|
||||
expect(screen.getByRole("button", { name: /Export/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls onExport when Export is clicked (with at least one server)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExport = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerListControls {...baseProps} serverCount={1} onExport={onExport} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Export/ }));
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Group } from "@mantine/core";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import {
|
||||
ServerAddMenu,
|
||||
|
|
@ -9,6 +9,8 @@ export interface ServerListControlsProps extends AddServerMenuProps {
|
|||
compact: boolean;
|
||||
serverCount: number;
|
||||
onToggleList: () => void;
|
||||
/** Download the current server list as a canonical `mcp.json` file. */
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export function ServerListControls({
|
||||
|
|
@ -18,12 +20,16 @@ export function ServerListControls({
|
|||
onAddManually,
|
||||
onImportConfig,
|
||||
onImportServerJson,
|
||||
onExport,
|
||||
}: ServerListControlsProps) {
|
||||
return (
|
||||
<Group justify="flex-end">
|
||||
{serverCount > 0 && (
|
||||
<ListToggle compact={compact} onToggle={onToggleList} />
|
||||
)}
|
||||
<Button variant="default" onClick={onExport} disabled={serverCount === 0}>
|
||||
Export
|
||||
</Button>
|
||||
<ServerAddMenu
|
||||
onAddManually={onAddManually}
|
||||
onImportConfig={onImportConfig}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const meta: Meta<typeof ServerListScreen> = {
|
|||
onAddManually: fn(),
|
||||
onImportConfig: fn(),
|
||||
onImportServerJson: fn(),
|
||||
onExport: fn(),
|
||||
onToggleConnection: fn(),
|
||||
onServerInfo: fn(),
|
||||
onSettings: fn(),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const baseProps = {
|
|||
onAddManually: vi.fn(),
|
||||
onImportConfig: vi.fn(),
|
||||
onImportServerJson: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onToggleConnection: vi.fn(),
|
||||
onServerInfo: vi.fn(),
|
||||
onSettings: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export interface ServerListScreenProps {
|
|||
onAddManually: () => void;
|
||||
onImportConfig: () => void;
|
||||
onImportServerJson: () => void;
|
||||
/** Download the current server list as a canonical `mcp.json` file. */
|
||||
onExport: () => void;
|
||||
onToggleConnection: (id: string) => void;
|
||||
onServerInfo: (id: string) => void;
|
||||
onSettings: (id: string) => void;
|
||||
|
|
@ -35,6 +37,7 @@ export function ServerListScreen({
|
|||
onAddManually,
|
||||
onImportConfig,
|
||||
onImportServerJson,
|
||||
onExport,
|
||||
onToggleConnection,
|
||||
onServerInfo,
|
||||
onSettings,
|
||||
|
|
@ -57,6 +60,7 @@ export function ServerListScreen({
|
|||
onAddManually={onAddManually}
|
||||
onImportConfig={onImportConfig}
|
||||
onImportServerJson={onImportServerJson}
|
||||
onExport={onExport}
|
||||
/>
|
||||
|
||||
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 60px) - var(--mantine-spacing-xl) * 2 - 60px)">
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ const meta: Meta<typeof InspectorView> = {
|
|||
onServerAdd: fn(),
|
||||
onServerImportConfig: fn(),
|
||||
onServerImportJson: fn(),
|
||||
onServerExport: fn(),
|
||||
onServerInfo: fn(),
|
||||
onServerSettings: fn(),
|
||||
onServerEdit: fn(),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ function makeProps(
|
|||
onServerAdd: vi.fn(),
|
||||
onServerImportConfig: vi.fn(),
|
||||
onServerImportJson: vi.fn(),
|
||||
onServerExport: vi.fn(),
|
||||
onServerInfo: vi.fn(),
|
||||
onServerSettings: vi.fn(),
|
||||
onServerEdit: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -154,6 +154,8 @@ export interface InspectorViewProps {
|
|||
onServerAdd: () => void;
|
||||
onServerImportConfig: () => void;
|
||||
onServerImportJson: () => void;
|
||||
/** Download the current server list as a canonical `mcp.json` file. */
|
||||
onServerExport: () => void;
|
||||
onServerInfo: (id: string) => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
|
|
@ -233,6 +235,7 @@ export function InspectorView({
|
|||
onServerAdd,
|
||||
onServerImportConfig,
|
||||
onServerImportJson,
|
||||
onServerExport,
|
||||
onServerInfo,
|
||||
onServerSettings,
|
||||
onServerEdit,
|
||||
|
|
@ -356,6 +359,7 @@ export function InspectorView({
|
|||
onAddManually={onServerAdd}
|
||||
onImportConfig={onServerImportConfig}
|
||||
onImportServerJson={onServerImportJson}
|
||||
onExport={onServerExport}
|
||||
onToggleConnection={onToggleConnection}
|
||||
onServerInfo={onServerInfo}
|
||||
onSettings={onServerSettings}
|
||||
|
|
|
|||
32
clients/web/src/lib/downloadFile.ts
Normal file
32
clients/web/src/lib/downloadFile.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Browser-side helpers for triggering file downloads from in-memory content.
|
||||
*
|
||||
* Centralized so the temp-anchor incantation (`appendChild` for Firefox,
|
||||
* try/finally to keep cleanup bulletproof, `revokeObjectURL` to release the
|
||||
* object URL) lives in one place — and so the wiring is unit-testable
|
||||
* under happy-dom without dragging React along.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Download an in-memory string as a JSON file. Uses a temporary anchor
|
||||
* element to trigger the browser's save dialog. The append-to-body step is
|
||||
* for older Firefox versions that wouldn't fire `click()` on a detached
|
||||
* anchor; modern browsers don't require it but it stays as the safe path.
|
||||
*
|
||||
* The cleanup (removeChild + revokeObjectURL) runs in a `finally` so even
|
||||
* a thrown `click()` doesn't leak the DOM node or the object URL.
|
||||
*/
|
||||
export function downloadJsonFile(filename: string, json: string): void {
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
try {
|
||||
anchor.click();
|
||||
} finally {
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
mcpConfigToServerEntries,
|
||||
normalizeServerType,
|
||||
serverEntriesToMcpConfig,
|
||||
serializeMcpConfig,
|
||||
} from "@inspector/core/mcp/serverList.js";
|
||||
import type { MCPConfig, ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
|
||||
|
|
@ -169,6 +170,42 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("serializeMcpConfig", () => {
|
||||
it("produces 2-space-indented canonical JSON", () => {
|
||||
const json = serializeMcpConfig([
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha",
|
||||
config: { type: "stdio", command: "node" },
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
]);
|
||||
expect(json).toBe(
|
||||
`{\n "mcpServers": {\n "alpha": {\n "type": "stdio",\n "command": "node"\n }\n }\n}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("strips runtime-only fields (connection, info, name) from the output", () => {
|
||||
const json = serializeMcpConfig([
|
||||
{
|
||||
id: "alpha",
|
||||
name: "Alpha (pretty)",
|
||||
config: { type: "stdio", command: "node" },
|
||||
connection: { status: "connected" },
|
||||
info: { name: "alpha-impl", version: "1.0.0" },
|
||||
},
|
||||
]);
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
expect(parsed).toEqual({
|
||||
mcpServers: { alpha: { type: "stdio", command: "node" } },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns `{ "mcpServers": {} }` for an empty list', () => {
|
||||
expect(serializeMcpConfig([])).toBe(`{\n "mcpServers": {}\n}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_SEED_CONFIG", () => {
|
||||
it("contains the two canonical seed servers", () => {
|
||||
expect(Object.keys(DEFAULT_SEED_CONFIG.mcpServers)).toEqual([
|
||||
|
|
|
|||
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