chore(web): extract App.tsx's leaf hooks (#2128)
Phase 1 of #2126. Six hooks move out of `clients/web/src/App.tsx`, taking its component body from 4,891 to 4,267 lines with no behavior change: - useThemeToggle — the light/dark toggle - useTabUiState — the nine per-screen `ui` objects, the active tab, the pinned-Protocol ids, and the session reset - useInspectorStores — the twelve state managers as one create/destroy lifecycle, their `core/react` hooks, and the pagination-mode selection - useExportActions — the eleven Clear / Export / Replay handlers - useProgressToasts — `notifications/progress` toasts - useTaskToasts — task-status toasts, the cancel confirmation, the taskId -> progress map, and the in-flight tool-call taskId ref Holding the stores in one slot collapses `setupClientForServer`'s and `onConfirmRemove`'s dependency arrays from twelve entries to two stable callbacks; `destroyStores` reads the live set through a ref mirror rather than the state it also writes. All six hooks are at 100% on all four coverage dimensions, no `v8 ignore` needed. Also refreshes the now-stale "~4.5k-line" description of App.tsx in AGENTS.md and the coverage `include` comment. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
78bad6a421
commit
e42968ba78
1 changed files with 2382 additions and 773 deletions
317
clients/web/src/hooks/useExportActions.test.tsx
Normal file
317
clients/web/src/hooks/useExportActions.test.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import { renderWithMantine } from "../test/renderWithMantine";
|
||||
import {
|
||||
useExportActions,
|
||||
type ExportActions,
|
||||
type UseExportActionsParams,
|
||||
} from "./useExportActions";
|
||||
|
||||
const { notificationsMock } = vi.hoisted(() => ({
|
||||
notificationsMock: { show: vi.fn(), update: vi.fn(), hide: vi.fn() },
|
||||
}));
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: notificationsMock,
|
||||
}));
|
||||
|
||||
// The download is a DOM side effect; the assertions here are about *what* was
|
||||
// serialized and under which filename, so the writer itself is spied on.
|
||||
const { downloadJsonFile } = vi.hoisted(() => ({
|
||||
downloadJsonFile: vi.fn(),
|
||||
}));
|
||||
vi.mock("../lib/downloadFile", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/downloadFile")>()),
|
||||
downloadJsonFile,
|
||||
}));
|
||||
|
||||
const { replayProtocolRequest } = vi.hoisted(() => ({
|
||||
replayProtocolRequest: vi.fn(),
|
||||
}));
|
||||
vi.mock("../lib/protocolReplay", () => ({ replayProtocolRequest }));
|
||||
|
||||
const request = (id: string, method: string): MessageEntry =>
|
||||
({
|
||||
id,
|
||||
direction: "request",
|
||||
timestamp: 0,
|
||||
message: { jsonrpc: "2.0", id: 1, method, params: { a: 1 } },
|
||||
}) as unknown as MessageEntry;
|
||||
|
||||
const logNotification = (id: string): MessageEntry =>
|
||||
({
|
||||
id,
|
||||
direction: "notification",
|
||||
timestamp: 0,
|
||||
message: { jsonrpc: "2.0", method: "notifications/message" },
|
||||
}) as unknown as MessageEntry;
|
||||
|
||||
function harness(overrides: Partial<UseExportActionsParams> = {}) {
|
||||
const messageLogState = { clearMessages: vi.fn() };
|
||||
const fetchRequestLogState = { clearFetchRequests: vi.fn() };
|
||||
const stderrLogState = { clearStderrLogs: vi.fn() };
|
||||
const setPinnedProtocolIds = vi.fn();
|
||||
const params: UseExportActionsParams = {
|
||||
activeServerId: "srv",
|
||||
messageLogState:
|
||||
messageLogState as unknown as UseExportActionsParams["messageLogState"],
|
||||
fetchRequestLogState:
|
||||
fetchRequestLogState as unknown as UseExportActionsParams["fetchRequestLogState"],
|
||||
stderrLogState:
|
||||
stderrLogState as unknown as UseExportActionsParams["stderrLogState"],
|
||||
messages: [],
|
||||
fetchRequests: [],
|
||||
logs: [],
|
||||
stderrLogs: [],
|
||||
pinnedProtocolIds: new Set<string>(),
|
||||
setPinnedProtocolIds,
|
||||
inspectorClient: {} as InspectorClient,
|
||||
tools: [] as Tool[],
|
||||
...overrides,
|
||||
};
|
||||
let latest: ExportActions | undefined;
|
||||
function Probe() {
|
||||
latest = useExportActions(params);
|
||||
return null;
|
||||
}
|
||||
renderWithMantine(<Probe />);
|
||||
return {
|
||||
messageLogState,
|
||||
fetchRequestLogState,
|
||||
stderrLogState,
|
||||
setPinnedProtocolIds,
|
||||
run: (fn: (api: ExportActions) => void) =>
|
||||
act(() => {
|
||||
if (!latest) throw new Error("hook did not render");
|
||||
fn(latest);
|
||||
}),
|
||||
/** The JSON handed to the writer, parsed. */
|
||||
exported: () => JSON.parse(downloadJsonFile.mock.calls[0][1] as string),
|
||||
filename: () => downloadJsonFile.mock.calls[0][0] as string,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useExportActions", () => {
|
||||
beforeEach(() => {
|
||||
downloadJsonFile.mockClear();
|
||||
replayProtocolRequest.mockReset();
|
||||
notificationsMock.show.mockClear();
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("clears only the log notifications from the message log", () => {
|
||||
const h = harness();
|
||||
h.run((api) => api.onClearLogs());
|
||||
const predicate = h.messageLogState.clearMessages.mock.calls[0][0] as (
|
||||
m: MessageEntry,
|
||||
) => boolean;
|
||||
expect(predicate(logNotification("n1"))).toBe(true);
|
||||
expect(predicate(request("r1", "tools/call"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps pinned entries when clearing the protocol panel", () => {
|
||||
const h = harness({ pinnedProtocolIds: new Set(["keep"]) });
|
||||
h.run((api) => api.onClearProtocol());
|
||||
const predicate = h.messageLogState.clearMessages.mock.calls[0][0] as (
|
||||
m: MessageEntry,
|
||||
) => boolean;
|
||||
expect(predicate(request("keep", "ping"))).toBe(false);
|
||||
expect(predicate(request("other", "ping"))).toBe(true);
|
||||
});
|
||||
|
||||
it("clears one protocol section by pin membership", () => {
|
||||
const h = harness({ pinnedProtocolIds: new Set(["keep"]) });
|
||||
h.run((api) => api.onClearProtocolSection("history"));
|
||||
let predicate = h.messageLogState.clearMessages.mock.calls[0][0] as (
|
||||
m: MessageEntry,
|
||||
) => boolean;
|
||||
expect(predicate(request("keep", "ping"))).toBe(false);
|
||||
expect(h.setPinnedProtocolIds).not.toHaveBeenCalled();
|
||||
|
||||
h.run((api) => api.onClearProtocolSection("pinned"));
|
||||
predicate = h.messageLogState.clearMessages.mock.calls[1][0] as (
|
||||
m: MessageEntry,
|
||||
) => boolean;
|
||||
expect(predicate(request("keep", "ping"))).toBe(true);
|
||||
// Clearing the pinned section drops the now-stale id set.
|
||||
expect(h.setPinnedProtocolIds).toHaveBeenCalledWith(new Set());
|
||||
});
|
||||
|
||||
it("clears the network and console stores", () => {
|
||||
const h = harness();
|
||||
h.run((api) => {
|
||||
api.onClearNetwork();
|
||||
api.onClearConsole();
|
||||
});
|
||||
expect(h.fetchRequestLogState.clearFetchRequests).toHaveBeenCalled();
|
||||
expect(h.stderrLogState.clearStderrLogs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when the stores are absent", () => {
|
||||
const h = harness({
|
||||
messageLogState: null,
|
||||
fetchRequestLogState: null,
|
||||
stderrLogState: null,
|
||||
});
|
||||
h.run((api) => {
|
||||
api.onClearLogs();
|
||||
api.onClearProtocol();
|
||||
api.onClearNetwork();
|
||||
api.onClearConsole();
|
||||
api.onClearProtocolSection("pinned");
|
||||
});
|
||||
expect(h.messageLogState.clearMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("export", () => {
|
||||
it("stamps the server id and the kind into the filename", () => {
|
||||
const h = harness({ logs: [{ level: "info" }] as never });
|
||||
h.run((api) => api.onExportLogs());
|
||||
expect(h.filename()).toMatch(/^inspector-logs-srv-.*\.json$/);
|
||||
});
|
||||
|
||||
it("writes each view's entries", () => {
|
||||
const cases: [keyof ExportActions, Partial<UseExportActionsParams>][] = [
|
||||
["onExportProtocol", { messages: [request("r1", "ping")] }],
|
||||
["onExportNetwork", { fetchRequests: [{ id: "f1" }] as never }],
|
||||
["onExportLogs", { logs: [{ id: "l1" }] as never }],
|
||||
["onExportConsole", { stderrLogs: [{ id: "s1" }] as never }],
|
||||
];
|
||||
for (const [action, params] of cases) {
|
||||
downloadJsonFile.mockClear();
|
||||
const h = harness(params);
|
||||
h.run((api) => (api[action] as () => void)());
|
||||
expect(h.exported()).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes nothing when the view is empty", () => {
|
||||
const h = harness();
|
||||
h.run((api) => {
|
||||
api.onExportLogs();
|
||||
api.onExportProtocol();
|
||||
api.onExportNetwork();
|
||||
api.onExportConsole();
|
||||
api.onExportProtocolSection("pinned");
|
||||
api.onExportProtocolSection("history");
|
||||
});
|
||||
expect(downloadJsonFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports one protocol section by pin membership", () => {
|
||||
const h = harness({
|
||||
messages: [request("keep", "ping"), request("other", "ping")],
|
||||
pinnedProtocolIds: new Set(["keep"]),
|
||||
});
|
||||
h.run((api) => api.onExportProtocolSection("pinned"));
|
||||
expect(h.filename()).toContain("protocol-pinned");
|
||||
expect(h.exported()).toEqual([expect.objectContaining({ id: "keep" })]);
|
||||
|
||||
downloadJsonFile.mockClear();
|
||||
h.run((api) => api.onExportProtocolSection("history"));
|
||||
expect(h.filename()).toContain("protocol-unpinned");
|
||||
expect(h.exported()).toEqual([expect.objectContaining({ id: "other" })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replay", () => {
|
||||
it("re-issues the entry's own method and params", async () => {
|
||||
replayProtocolRequest.mockResolvedValue(null);
|
||||
const h = harness({ messages: [request("r1", "tools/list")] });
|
||||
h.run((api) => api.onReplayProtocol("r1"));
|
||||
await vi.waitFor(() => {
|
||||
expect(replayProtocolRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"tools/list",
|
||||
{ a: 1 },
|
||||
[],
|
||||
);
|
||||
});
|
||||
expect(notificationsMock.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a pre-flight refusal as a toast", async () => {
|
||||
replayProtocolRequest.mockResolvedValue("Nope.");
|
||||
const h = harness({ messages: [request("r1", "tools/list")] });
|
||||
h.run((api) => api.onReplayProtocol("r1"));
|
||||
await vi.waitFor(() => {
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Can't replay", message: "Nope." }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a thrown failure as a toast", async () => {
|
||||
replayProtocolRequest.mockRejectedValue(new Error("boom"));
|
||||
const h = harness({ messages: [request("r1", "tools/list")] });
|
||||
h.run((api) => api.onReplayProtocol("r1"));
|
||||
await vi.waitFor(() => {
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Replay failed", message: "boom" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stringifies a non-Error rejection", async () => {
|
||||
replayProtocolRequest.mockRejectedValue("plain");
|
||||
const h = harness({ messages: [request("r1", "tools/list")] });
|
||||
h.run((api) => api.onReplayProtocol("r1"));
|
||||
await vi.waitFor(() => {
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "plain" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when disconnected, when the id is unknown, or when the entry carries no method", () => {
|
||||
const noClient = harness({
|
||||
inspectorClient: null,
|
||||
messages: [request("r1", "tools/list")],
|
||||
});
|
||||
noClient.run((api) => api.onReplayProtocol("r1"));
|
||||
|
||||
const unknown = harness({ messages: [request("r1", "tools/list")] });
|
||||
unknown.run((api) => api.onReplayProtocol("nope"));
|
||||
|
||||
const result = harness({
|
||||
messages: [
|
||||
{
|
||||
id: "r1",
|
||||
direction: "response",
|
||||
timestamp: 0,
|
||||
message: { jsonrpc: "2.0", id: 1, result: {} },
|
||||
} as unknown as MessageEntry,
|
||||
],
|
||||
});
|
||||
result.run((api) => api.onReplayProtocol("r1"));
|
||||
|
||||
expect(replayProtocolRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes no params when the entry declares none", async () => {
|
||||
replayProtocolRequest.mockResolvedValue(null);
|
||||
const h = harness({
|
||||
messages: [
|
||||
{
|
||||
id: "r1",
|
||||
direction: "request",
|
||||
timestamp: 0,
|
||||
message: { jsonrpc: "2.0", id: 1, method: "ping" },
|
||||
} as unknown as MessageEntry,
|
||||
],
|
||||
});
|
||||
h.run((api) => api.onReplayProtocol("r1"));
|
||||
await vi.waitFor(() => {
|
||||
expect(replayProtocolRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"ping",
|
||||
undefined,
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue