inspector/clients/web/src/hooks/useInspectorStores.test.tsx
cliffhall 4d7dd180d9 fix: address Copilot review round 13 on #2251
- SkillsScreen: put the SESSION in the invalidation key. This screen stays
  mounted across a disconnect, so content alone cannot tell server A's entry
  from an identical-looking one on server B — A's in-flight verification
  could land afterwards and report `verified` for a file never read from B,
  and a retained batch entry could leave B's Verify all disabled.
  `useInspectorStores` now exposes a `sessionNonce`, bumped on both create
  and destroy so it never repeats across a reconnect, and App keys on
  `${activeServerId}:${sessionNonce}` — the server id alone would repeat,
  which is one of the crossings this exists to prevent.
- SkillsScreen: match the selection by `skillUriIdentity`, in both the
  lookup and the NavLink active check. A refresh that canonicalizes
  `skill://demo/%53KILL.md` names the same skill, and the detail pane was
  emptying out because the server changed its spelling — the last raw-string
  URI comparison left after round 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 01:40:28 -04:00

338 lines
12 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { act } from "react";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { renderWithMantine } from "../test/renderWithMantine";
import {
useInspectorStores,
type UseInspectorStoresResult,
} from "./useInspectorStores";
// Every state manager is replaced by a no-op constructor that records its
// arguments, so these tests are about the *lifecycle* the hook owns — who is
// built, with what, and who is torn down — rather than about the stores.
const { built, destroyed, fakeState, listHook, pagedHook } = vi.hoisted(() => {
const built: { name: string; args: unknown[]; destroy: () => void }[] = [];
const destroyed: string[] = [];
/** A no-op state manager that records how it was constructed. */
const fakeState = (name: string) =>
vi.fn(function (...args: unknown[]) {
const instance = { destroy: vi.fn(() => destroyed.push(name)) };
built.push({ name, args, destroy: instance.destroy });
return instance;
});
/** An aggregate-list `core/react` hook returning a fixed list. */
const listHook = (key: string, items: unknown[]) => () => ({
[key]: items,
error: null,
listChanged: false,
refresh: vi.fn().mockResolvedValue(undefined),
clearListChanged: vi.fn(),
});
/** A paged-list `core/react` hook returning a fixed page. */
const pagedHook = (key: string, items: unknown[]) => () => ({
[key]: items,
nextCursor: undefined,
pageCount: 1,
error: null,
loadPage: vi.fn().mockResolvedValue(undefined),
});
return { built, destroyed, fakeState, listHook, pagedHook };
});
vi.mock("@inspector/core/mcp/state/managedToolsState.js", () => ({
ManagedToolsState: fakeState("managedToolsState"),
}));
vi.mock("@inspector/core/mcp/state/managedPromptsState.js", () => ({
ManagedPromptsState: fakeState("managedPromptsState"),
}));
vi.mock("@inspector/core/mcp/state/managedResourcesState.js", () => ({
ManagedResourcesState: fakeState("managedResourcesState"),
}));
vi.mock("@inspector/core/mcp/state/pagedToolsState.js", () => ({
PagedToolsState: fakeState("pagedToolsState"),
}));
vi.mock("@inspector/core/mcp/state/pagedPromptsState.js", () => ({
PagedPromptsState: fakeState("pagedPromptsState"),
}));
vi.mock("@inspector/core/mcp/state/pagedResourcesState.js", () => ({
PagedResourcesState: fakeState("pagedResourcesState"),
}));
vi.mock("@inspector/core/mcp/state/managedResourceTemplatesState.js", () => ({
ManagedResourceTemplatesState: fakeState("managedResourceTemplatesState"),
}));
vi.mock("@inspector/core/mcp/state/managedRequestorTasksState.js", () => ({
ManagedRequestorTasksState: fakeState("managedRequestorTasksState"),
}));
vi.mock("@inspector/core/mcp/state/managedSkillsState.js", () => ({
ManagedSkillsState: fakeState("managedSkillsState"),
}));
vi.mock("@inspector/core/mcp/state/resourceSubscriptionsState.js", () => ({
ResourceSubscriptionsState: fakeState("resourceSubscriptionsState"),
}));
vi.mock("@inspector/core/mcp/state/messageLogState.js", () => ({
MessageLogState: fakeState("messageLogState"),
}));
vi.mock("@inspector/core/mcp/state/fetchRequestLogState.js", () => ({
FetchRequestLogState: fakeState("fetchRequestLogState"),
}));
vi.mock("@inspector/core/mcp/state/stderrLogState.js", () => ({
StderrLogState: fakeState("stderrLogState"),
}));
// The `core/react` hooks are the store→React bridge and are tested in core;
// here they are stubs returning fixed lists, so the assertions about *which*
// list surfaces are about this hook's mode selection.
vi.mock("@inspector/core/react/useManagedTools.js", () => ({
useManagedTools: listHook("tools", [{ name: "aggregate-tool" }]),
}));
vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({
useManagedPrompts: listHook("prompts", [{ name: "aggregate-prompt" }]),
}));
vi.mock("@inspector/core/react/useManagedResources.js", () => ({
useManagedResources: listHook("resources", [{ uri: "aggregate://r" }]),
}));
vi.mock("@inspector/core/react/usePagedTools.js", () => ({
usePagedTools: pagedHook("tools", [{ name: "paged-tool" }]),
}));
vi.mock("@inspector/core/react/usePagedPrompts.js", () => ({
usePagedPrompts: pagedHook("prompts", [{ name: "paged-prompt" }]),
}));
vi.mock("@inspector/core/react/usePagedResources.js", () => ({
usePagedResources: pagedHook("resources", [{ uri: "paged://r" }]),
}));
vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
useManagedResourceTemplates: () => ({
resourceTemplates: [{ uriTemplate: "x://{id}" }],
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
useManagedRequestorTasks: () => ({
tasks: [{ taskId: "t1" }],
refresh: vi.fn().mockResolvedValue(undefined),
clearCompleted: vi.fn(),
}),
}));
vi.mock("@inspector/core/react/useManagedSkills.js", () => ({
useManagedSkills: () => ({
skills: [{ uri: "skill://s/SKILL.md" }],
pageCount: 1,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({
useResourceSubscriptions: () => ({
subscriptions: [{ uri: "sub://r" }],
streamState: "listening",
}),
}));
vi.mock("@inspector/core/react/useMessageLog.js", () => ({
useMessageLog: () => ({ messages: [{ id: "m1" }] }),
}));
vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({
useFetchRequestLog: () => ({ fetchRequests: [{ id: "f1" }] }),
}));
vi.mock("@inspector/core/react/useStderrLog.js", () => ({
useStderrLog: () => ({ stderrLogs: [{ id: "s1" }] }),
}));
/**
* A per-call identity token standing in for the client. Every store
* constructor is mocked away below, so nothing here ever reads a property off
* it — the assertions only check *which* value each constructor received, and
* that a second `createStores` passes a different one. The double cast is what
* that isolation buys: building a real `InspectorClient` would drag in a
* transport and an environment to assert on object identity.
*/
const client = () =>
({ marker: Symbol("client") }) as unknown as InspectorClient;
const fetchLogOptions = {
maxFetchRequests: 42,
sessionId: "sess-1",
};
function harness(paginatedLists = false) {
let latest: UseInspectorStoresResult | undefined;
let renders = 0;
function Probe({ paginated }: { paginated: boolean }) {
latest = useInspectorStores({
inspectorClient: null,
connected: true,
paginatedLists: paginated,
});
renders += 1;
return null;
}
const { rerender } = renderWithMantine(<Probe paginated={paginatedLists} />);
return {
api: () => {
if (!latest) throw new Error("hook did not render");
return latest;
},
renders: () => renders,
run: (fn: (api: UseInspectorStoresResult) => void) =>
act(() => {
if (!latest) throw new Error("hook did not render");
fn(latest);
}),
setPaginated: (next: boolean) =>
act(() => rerender(<Probe paginated={next} />)),
};
}
const STORE_NAMES = [
"managedToolsState",
"managedPromptsState",
"managedResourcesState",
"pagedToolsState",
"pagedPromptsState",
"pagedResourcesState",
"managedResourceTemplatesState",
"managedRequestorTasksState",
"managedSkillsState",
"resourceSubscriptionsState",
"messageLogState",
"fetchRequestLogState",
"stderrLogState",
];
describe("useInspectorStores", () => {
beforeEach(() => {
built.length = 0;
destroyed.length = 0;
});
describe("the lifecycle", () => {
it("holds no stores before the first connect", () => {
const h = harness();
expect(h.api().stores).toBeNull();
expect(h.api().fetchLogRef.current).toBeNull();
expect(built).toHaveLength(0);
});
it("builds all thirteen stores against the client", () => {
const h = harness();
const c = client();
h.run((api) => api.createStores(c, fetchLogOptions));
expect(built.map((b) => b.name).sort()).toEqual([...STORE_NAMES].sort());
for (const b of built) {
expect(b.args[0]).toBe(c);
}
expect(Object.keys(h.api().stores ?? {}).sort()).toEqual(
[...STORE_NAMES].sort(),
);
});
it("hands the subscription store the resources state it just built", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
const subs = built.find((b) => b.name === "resourceSubscriptionsState");
expect(subs?.args[1]).toBe(h.api().stores?.managedResourcesState);
});
it("passes the per-connect fetch-log options straight through", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
const log = built.find((b) => b.name === "fetchRequestLogState");
expect(log?.args[1]).toBe(fetchLogOptions);
});
it("points fetchLogRef at the live fetch log", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
expect(h.api().fetchLogRef.current).toBe(
h.api().stores?.fetchRequestLogState,
);
});
it("tears the previous set down before building the next", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
const first = h.api().stores;
h.run((api) => api.createStores(client(), fetchLogOptions));
expect(destroyed.sort()).toEqual([...STORE_NAMES].sort());
expect(h.api().stores).not.toBe(first);
});
it("advances the session nonce on both create and destroy", () => {
// It names one connected session and must never repeat across a
// reconnect — `SkillsScreen` keys async verification state on it, and a
// repeated value would let one session's result land in another.
const h = harness();
const seen = new Set<number>([h.api().sessionNonce]);
h.run((api) => api.createStores(client(), fetchLogOptions));
seen.add(h.api().sessionNonce);
h.run((api) => api.destroyStores());
seen.add(h.api().sessionNonce);
h.run((api) => api.createStores(client(), fetchLogOptions));
seen.add(h.api().sessionNonce);
expect(seen.size).toBe(4);
});
it("destroys and clears on destroyStores", () => {
const h = harness();
h.run((api) => api.createStores(client(), fetchLogOptions));
h.run((api) => api.destroyStores());
expect(destroyed.sort()).toEqual([...STORE_NAMES].sort());
expect(h.api().stores).toBeNull();
expect(h.api().fetchLogRef.current).toBeNull();
});
it("no-ops when destroyStores runs with nothing live", () => {
const h = harness();
h.run((api) => api.destroyStores());
expect(destroyed).toHaveLength(0);
expect(h.api().stores).toBeNull();
});
it("keeps createStores and destroyStores stable across renders", () => {
const h = harness();
const create = h.api().createStores;
const destroy = h.api().destroyStores;
h.run((api) => api.createStores(client(), fetchLogOptions));
expect(h.renders()).toBeGreaterThan(1);
expect(h.api().createStores).toBe(create);
expect(h.api().destroyStores).toBe(destroy);
});
});
describe("the display source", () => {
it("reads the aggregate lists in all-pages mode", () => {
const h = harness(false);
expect(h.api().toolsPagination.items).toEqual([
{ name: "aggregate-tool" },
]);
expect(h.api().promptsPagination.items).toEqual([
{ name: "aggregate-prompt" },
]);
expect(h.api().resourcesPagination.items).toEqual([
{ uri: "aggregate://r" },
]);
});
it("switches to the paged lists when pagination is on", () => {
const h = harness(false);
h.setPaginated(true);
expect(h.api().toolsPagination.items).toEqual([{ name: "paged-tool" }]);
expect(h.api().promptsPagination.items).toEqual([
{ name: "paged-prompt" },
]);
expect(h.api().resourcesPagination.items).toEqual([{ uri: "paged://r" }]);
});
it("surfaces the remaining store-backed views", () => {
const h = harness();
const api = h.api();
expect(api.resourceTemplates).toEqual([{ uriTemplate: "x://{id}" }]);
expect(api.tasks).toEqual([{ taskId: "t1" }]);
expect(api.subscriptions).toEqual([{ uri: "sub://r" }]);
expect(api.subscriptionStreamState).toBe("listening");
expect(api.messages).toEqual([{ id: "m1" }]);
expect(api.fetchRequests).toEqual([{ id: "f1" }]);
expect(api.stderrLogs).toEqual([{ id: "s1" }]);
});
});
});