* feat(core): port v1.5 hook layer (useInspectorClient + useManaged*/usePaged* + log hooks) Wholesale port of v1.5's React hook layer and the EventTarget-based state machinery the hooks subscribe to (#1243). Hooks decouple from the concrete InspectorClient via a new `InspectorClientProtocol` interface so this layer can land before the runtime class is ported. Added under `core/`: - `core/react/` — useInspectorClient + 5 useManaged* + 5 usePaged* + 3 log hooks - `core/mcp/state/` — 5 managed*State + 5 paged*State + messageLogState + stderrLogState + fetchRequestLogState - `core/mcp/inspectorClientProtocol.ts`, `inspectorClientEventTarget.ts`, `typedEventTarget.ts`, `samplingCreateMessage.ts`, `sessionStorage.ts` - v2-only wrapper types in `core/mcp/types.ts`: InspectorUrlElicitRequest, InspectorPendingRequest, InspectorRequestHistoryItem Tests live under `clients/web/src/test/core/` (28 new test files, 264 new tests). Test fake: `core/mcp/__tests__/fakeInspectorClient.ts`. Build wiring: - `vite.config.ts`: dedupe react/react-dom so core/ files resolve them via clients/web/node_modules. - `tsconfig.app.json` / `tsconfig.test.json`: paths map react/react-dom/vitest to clients/web/node_modules so `tsc -b` resolves bare imports from core/ source files (which sit above the package's node_modules). Exclude core/__tests__ from app config; include in test config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: address PR #1300 review findings Bugs: - fetchRequestLogState: add .catch() on sessionStorage.loadSession so a corrupt or unreadable session doesn't surface as an unhandled rejection (matches the saveSession swallow pattern). - messageLogState: sync pendingRequestEntries when clearMessages() evicts a pending request entry, so a later response with the same id falls through to the append branch instead of mutating an unreachable map entry. Add tests for both predicate and no-predicate paths. Cleanups: - Extract mergeTaskIntoList helper to core/mcp/state/mergeTaskIntoList.ts so managed/paged RequestorTasks state classes share one definition. - Add a one-line JSDoc note on each PagedTools/Prompts/Resources/Templates state explaining why they deliberately do not subscribe to *ListChanged (cursor invalidation mid-pagination). - Document on useInspectorClient that appRendererClient is read lazily on every render and is not subscribed (changes once at connect time). Carry-overs from v1.5 deliberately left for the follow-up InspectorClient runtime port: - Refresh/status-change race in Managed*State.refresh() (status check is only at entry; a disconnect during await can re-clear before refresh resumes). - nextCursor exposure asymmetry across usePaged* (only usePagedRequestorTasks surfaces it via state field; others return it from loadPage() only). 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
a4215e57b9
commit
e3e70470b5
65 changed files with 6404 additions and 5 deletions
214
clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts
Normal file
214
clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { FetchRequestEntry } from "@inspector/core/mcp/types";
|
||||
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import type {
|
||||
InspectorClientStorage,
|
||||
InspectorClientSessionState,
|
||||
} from "@inspector/core/mcp/sessionStorage";
|
||||
|
||||
function entry(id: string, url = `https://x/${id}`): FetchRequestEntry {
|
||||
return {
|
||||
id,
|
||||
timestamp: new Date(2026, 4, 13),
|
||||
method: "GET",
|
||||
url,
|
||||
requestHeaders: {},
|
||||
category: "transport",
|
||||
};
|
||||
}
|
||||
|
||||
function makeStorage(
|
||||
initial: Record<string, InspectorClientSessionState> = {},
|
||||
): InspectorClientStorage & {
|
||||
saves: Array<{ id: string; state: InspectorClientSessionState }>;
|
||||
} {
|
||||
const store = new Map(Object.entries(initial));
|
||||
const saves: Array<{ id: string; state: InspectorClientSessionState }> = [];
|
||||
return {
|
||||
saves,
|
||||
async saveSession(sessionId, state) {
|
||||
saves.push({ id: sessionId, state });
|
||||
store.set(sessionId, state);
|
||||
},
|
||||
async loadSession(sessionId) {
|
||||
return store.get(sessionId);
|
||||
},
|
||||
async deleteSession(sessionId) {
|
||||
store.delete(sessionId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("FetchRequestLogState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: FetchRequestLogState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new FetchRequestLogState(client);
|
||||
});
|
||||
|
||||
it("starts empty and returns defensive copies", () => {
|
||||
expect(state.getFetchRequests()).toEqual([]);
|
||||
expect(state.getFetchRequests()).not.toBe(state.getFetchRequests());
|
||||
});
|
||||
|
||||
it("appends entries and dispatches fetchRequest + fetchRequestsChange", () => {
|
||||
const seenSingle: FetchRequestEntry[] = [];
|
||||
const seenFull: FetchRequestEntry[][] = [];
|
||||
state.addEventListener("fetchRequest", (e) => seenSingle.push(e.detail));
|
||||
state.addEventListener("fetchRequestsChange", (e) =>
|
||||
seenFull.push(e.detail),
|
||||
);
|
||||
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
client.dispatchTypedEvent("fetchRequest", entry("b"));
|
||||
|
||||
expect(state.getFetchRequests().map((e) => e.id)).toEqual(["a", "b"]);
|
||||
expect(seenSingle).toHaveLength(2);
|
||||
expect(seenFull).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("trims the oldest entries when maxFetchRequests is exceeded", () => {
|
||||
const small = new FetchRequestLogState(client, { maxFetchRequests: 2 });
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
client.dispatchTypedEvent("fetchRequest", entry("b"));
|
||||
client.dispatchTypedEvent("fetchRequest", entry("c"));
|
||||
expect(small.getFetchRequests().map((e) => e.id)).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("does not trim when maxFetchRequests is 0", () => {
|
||||
const big = new FetchRequestLogState(client, { maxFetchRequests: 0 });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.dispatchTypedEvent("fetchRequest", entry(`m${i}`));
|
||||
}
|
||||
expect(big.getFetchRequests()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("clearFetchRequests dispatches when the list was non-empty", () => {
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
let dispatched = false;
|
||||
state.addEventListener("fetchRequestsChange", () => (dispatched = true));
|
||||
state.clearFetchRequests();
|
||||
expect(dispatched).toBe(true);
|
||||
expect(state.getFetchRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clearFetchRequests is a no-op when empty", () => {
|
||||
let dispatched = false;
|
||||
state.addEventListener("fetchRequestsChange", () => (dispatched = true));
|
||||
state.clearFetchRequests();
|
||||
expect(dispatched).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT clear on connect or disconnect", () => {
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
client.dispatchTypedEvent("connect");
|
||||
expect(state.getFetchRequests().map((e) => e.id)).toEqual(["a"]);
|
||||
client.setStatus("disconnected");
|
||||
expect(state.getFetchRequests().map((e) => e.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("persists entries via saveSession when sessionStorage is provided", () => {
|
||||
const storage = makeStorage();
|
||||
const sessionState = new FetchRequestLogState(client, {
|
||||
sessionStorage: storage,
|
||||
});
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
client.dispatchTypedEvent("saveSession", { sessionId: "sess-1" });
|
||||
expect(storage.saves).toHaveLength(1);
|
||||
expect(storage.saves[0]!.id).toBe("sess-1");
|
||||
expect(storage.saves[0]!.state.fetchRequests.map((e) => e.id)).toEqual([
|
||||
"a",
|
||||
]);
|
||||
sessionState.destroy();
|
||||
});
|
||||
|
||||
it("swallows saveSession errors (fire and forget)", async () => {
|
||||
const storage = makeStorage();
|
||||
storage.saveSession = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
new FetchRequestLogState(client, { sessionStorage: storage });
|
||||
expect(() =>
|
||||
client.dispatchTypedEvent("saveSession", { sessionId: "sess-1" }),
|
||||
).not.toThrow();
|
||||
await Promise.resolve();
|
||||
expect(storage.saveSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hydrates from storage when sessionId is provided and entries exist", async () => {
|
||||
const storage = makeStorage({
|
||||
"sess-restore": {
|
||||
fetchRequests: [entry("r1"), entry("r2")],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
});
|
||||
const hydrated = new FetchRequestLogState(client, {
|
||||
sessionStorage: storage,
|
||||
sessionId: "sess-restore",
|
||||
});
|
||||
// hydrate is async via .then(); resolve microtasks
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hydrated.getFetchRequests().map((e) => e.id)).toEqual(["r1", "r2"]);
|
||||
hydrated.destroy();
|
||||
});
|
||||
|
||||
it("hydrate respects maxFetchRequests trimming", async () => {
|
||||
const storage = makeStorage({
|
||||
"sess-trim": {
|
||||
fetchRequests: [entry("r1"), entry("r2"), entry("r3")],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
});
|
||||
const hydrated = new FetchRequestLogState(client, {
|
||||
sessionStorage: storage,
|
||||
sessionId: "sess-trim",
|
||||
maxFetchRequests: 2,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hydrated.getFetchRequests().map((e) => e.id)).toEqual(["r2", "r3"]);
|
||||
});
|
||||
|
||||
it("ignores hydration when destroy() happens first", async () => {
|
||||
let resolveLoad:
|
||||
| ((s: InspectorClientSessionState | undefined) => void)
|
||||
| undefined;
|
||||
const storage: InspectorClientStorage = {
|
||||
async saveSession() {},
|
||||
loadSession: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
}),
|
||||
async deleteSession() {},
|
||||
};
|
||||
const hydrated = new FetchRequestLogState(client, {
|
||||
sessionStorage: storage,
|
||||
sessionId: "sess-late",
|
||||
});
|
||||
hydrated.destroy();
|
||||
resolveLoad?.({
|
||||
fetchRequests: [entry("late")],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(hydrated.getFetchRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", () => {
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
state.destroy();
|
||||
expect(state.getFetchRequests()).toEqual([]);
|
||||
client.dispatchTypedEvent("fetchRequest", entry("b"));
|
||||
expect(state.getFetchRequests()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
153
clients/web/src/test/core/mcp/state/managedPromptsState.test.ts
Normal file
153
clients/web/src/test/core/mcp/state/managedPromptsState.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function prompt(name: string): Prompt {
|
||||
return { name };
|
||||
}
|
||||
|
||||
function waitForPromptsChange(state: ManagedPromptsState): Promise<Prompt[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("promptsChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedPromptsState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedPromptsState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new ManagedPromptsState(client);
|
||||
});
|
||||
|
||||
it("starts with empty prompts", () => {
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("getPrompts returns a defensive copy", () => {
|
||||
const a = state.getPrompts();
|
||||
const b = state.getPrompts();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listPrompts when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches promptsChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
|
||||
|
||||
const changePromise = waitForPromptsChange(state);
|
||||
const result = await state.refresh();
|
||||
|
||||
expect(result.map((p) => p.name)).toEqual(["a", "b"]);
|
||||
expect(await changePromise).toEqual(result);
|
||||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages(
|
||||
{ prompts: [prompt("a")], nextCursor: "c1" },
|
||||
{ prompts: [prompt("b")], nextCursor: "c2" },
|
||||
{ prompts: [prompt("c")] },
|
||||
);
|
||||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((p) => p.name)).toEqual(["a", "b", "c"]);
|
||||
expect(client.listPrompts).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("refresh passes setMetadata-supplied metadata to listPrompts", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "v" });
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
expect(client.listPrompts).toHaveBeenCalledWith(undefined, { k: "v" });
|
||||
});
|
||||
|
||||
it("refresh argument overrides setMetadata", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "default" });
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh({ k: "override" });
|
||||
expect(client.listPrompts).toHaveBeenCalledWith(undefined, {
|
||||
k: "override",
|
||||
});
|
||||
});
|
||||
|
||||
it("connect event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
const changePromise = waitForPromptsChange(state);
|
||||
client.dispatchTypedEvent("connect");
|
||||
const next = await changePromise;
|
||||
expect(next.map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("promptsListChanged event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
|
||||
const changePromise = waitForPromptsChange(state);
|
||||
client.dispatchTypedEvent("promptsListChanged");
|
||||
const next = await changePromise;
|
||||
expect(next.map((p) => p.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears prompts and dispatches promptsChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getPrompts()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForPromptsChange(state);
|
||||
client.setStatus("disconnected");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other (non-disconnected) values does not clear prompts", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
client.setStatus("error");
|
||||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listPrompts.mockImplementation(async () => ({
|
||||
prompts: [prompt("a")],
|
||||
nextCursor: "always",
|
||||
}));
|
||||
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getPrompts()).toHaveLength(1);
|
||||
|
||||
state.destroy();
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
|
||||
client.queuePromptPages({ prompts: [prompt("b")] });
|
||||
client.dispatchTypedEvent("promptsListChanged");
|
||||
await Promise.resolve();
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Task } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function task(taskId: string, status: Task["status"] = "working"): Task {
|
||||
return {
|
||||
taskId,
|
||||
status,
|
||||
ttl: null,
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
lastUpdatedAt: "2026-05-13T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function waitForChange(state: ManagedRequestorTasksState): Promise<Task[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("tasksChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedRequestorTasksState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedRequestorTasksState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new ManagedRequestorTasksState(client);
|
||||
});
|
||||
|
||||
it("starts with empty tasks", () => {
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("getTasks returns a defensive copy", () => {
|
||||
const a = state.getTasks();
|
||||
const b = state.getTasks();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listRequestorTasks when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listRequestorTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches tasksChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
const result = await state.refresh();
|
||||
|
||||
expect(result.map((t) => t.taskId)).toEqual(["t1", "t2"]);
|
||||
expect(await changePromise).toEqual(result);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages(
|
||||
{ tasks: [task("t1")], nextCursor: "c1" },
|
||||
{ tasks: [task("t2")], nextCursor: "c2" },
|
||||
{ tasks: [task("t3")] },
|
||||
);
|
||||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((t) => t.taskId)).toEqual(["t1", "t2", "t3"]);
|
||||
expect(client.listRequestorTasks).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("connect event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("connect");
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.taskId)).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("tasksListChanged event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("tasksListChanged");
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.taskId)).toEqual(["t1", "t2"]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears tasks and dispatches tasksChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.refresh();
|
||||
expect(state.getTasks()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("disconnected");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other values does not clear tasks", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.refresh();
|
||||
client.setStatus("error");
|
||||
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("taskStatusChange inserts a new task when the id is unknown", async () => {
|
||||
client.setStatus("connected");
|
||||
const newTask = task("t1", "working");
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("taskStatusChange", {
|
||||
taskId: "t1",
|
||||
task: newTask,
|
||||
});
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.taskId)).toEqual(["t1"]);
|
||||
expect(next[0]!.status).toBe("working");
|
||||
});
|
||||
|
||||
it("taskStatusChange replaces an existing task in place", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({
|
||||
tasks: [task("t1", "working"), task("t2", "working")],
|
||||
});
|
||||
await state.refresh();
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("taskStatusChange", {
|
||||
taskId: "t1",
|
||||
task: task("t1", "completed"),
|
||||
});
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.taskId)).toEqual(["t1", "t2"]);
|
||||
expect(next.find((t) => t.taskId === "t1")!.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("requestorTaskUpdated falls back to lastUpdatedAt when createdAt is missing", async () => {
|
||||
client.setStatus("connected");
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("requestorTaskUpdated", {
|
||||
taskId: "t1",
|
||||
task: {
|
||||
taskId: "t1",
|
||||
status: "working",
|
||||
ttl: null,
|
||||
lastUpdatedAt: "2026-05-13T01:00:00Z",
|
||||
},
|
||||
});
|
||||
const next = await changePromise;
|
||||
expect(next[0]!.createdAt).toBe("2026-05-13T01:00:00Z");
|
||||
});
|
||||
|
||||
it("taskCancelled flips status to cancelled when the task is known", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1", "working")] });
|
||||
await state.refresh();
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("taskCancelled", { taskId: "t1" });
|
||||
const next = await changePromise;
|
||||
expect(next[0]!.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("taskCancelled is a no-op when the task is unknown", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.refresh();
|
||||
|
||||
let dispatched = false;
|
||||
state.addEventListener("tasksChange", () => {
|
||||
dispatched = true;
|
||||
});
|
||||
client.dispatchTypedEvent("taskCancelled", { taskId: "missing" });
|
||||
expect(dispatched).toBe(false);
|
||||
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listRequestorTasks.mockImplementation(async () => ({
|
||||
tasks: [task("t1")],
|
||||
nextCursor: "always",
|
||||
}));
|
||||
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.refresh();
|
||||
expect(state.getTasks()).toHaveLength(1);
|
||||
|
||||
state.destroy();
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
|
||||
client.queueTaskPages({ tasks: [task("t2")] });
|
||||
client.dispatchTypedEvent("tasksListChanged");
|
||||
await Promise.resolve();
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function template(name: string): ResourceTemplate {
|
||||
return { uriTemplate: `tpl://{${name}}`, name };
|
||||
}
|
||||
|
||||
function waitForChange(
|
||||
state: ManagedResourceTemplatesState,
|
||||
): Promise<ResourceTemplate[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener(
|
||||
"resourceTemplatesChange",
|
||||
(e) => resolve(e.detail),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedResourceTemplatesState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedResourceTemplatesState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new ManagedResourceTemplatesState(client);
|
||||
});
|
||||
|
||||
it("starts with empty resource templates", () => {
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("getResourceTemplates returns a defensive copy", () => {
|
||||
const a = state.getResourceTemplates();
|
||||
const b = state.getResourceTemplates();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listResourceTemplates when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listResourceTemplates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches resourceTemplatesChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({
|
||||
resourceTemplates: [template("a"), template("b")],
|
||||
});
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
const result = await state.refresh();
|
||||
|
||||
expect(result.map((t) => t.name)).toEqual(["a", "b"]);
|
||||
expect(await changePromise).toEqual(result);
|
||||
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages(
|
||||
{ resourceTemplates: [template("a")], nextCursor: "c1" },
|
||||
{ resourceTemplates: [template("b")], nextCursor: "c2" },
|
||||
{ resourceTemplates: [template("c")] },
|
||||
);
|
||||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((t) => t.name)).toEqual(["a", "b", "c"]);
|
||||
expect(client.listResourceTemplates).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("refresh passes setMetadata-supplied metadata", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "v" });
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, {
|
||||
k: "v",
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh argument overrides setMetadata", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "default" });
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh({ k: "override" });
|
||||
expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, {
|
||||
k: "override",
|
||||
});
|
||||
});
|
||||
|
||||
it("connect event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("connect");
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("resourceTemplatesListChanged event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({
|
||||
resourceTemplates: [template("a"), template("b")],
|
||||
});
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("resourceTemplatesListChanged");
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears templates and dispatches change", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getResourceTemplates()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("disconnected");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other values does not clear templates", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
client.setStatus("error");
|
||||
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listResourceTemplates.mockImplementation(async () => ({
|
||||
resourceTemplates: [template("a")],
|
||||
nextCursor: "always",
|
||||
}));
|
||||
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getResourceTemplates()).toHaveLength(1);
|
||||
|
||||
state.destroy();
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("b")] });
|
||||
client.dispatchTypedEvent("resourceTemplatesListChanged");
|
||||
await Promise.resolve();
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function resource(uri: string): Resource {
|
||||
return { uri, name: uri };
|
||||
}
|
||||
|
||||
function waitForResourcesChange(
|
||||
state: ManagedResourcesState,
|
||||
): Promise<Resource[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("resourcesChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedResourcesState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedResourcesState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new ManagedResourcesState(client);
|
||||
});
|
||||
|
||||
it("starts with empty resources", () => {
|
||||
expect(state.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("getResources returns a defensive copy", () => {
|
||||
const a = state.getResources();
|
||||
const b = state.getResources();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listResources when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches resourcesChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({
|
||||
resources: [resource("a://1"), resource("a://2")],
|
||||
});
|
||||
|
||||
const changePromise = waitForResourcesChange(state);
|
||||
const result = await state.refresh();
|
||||
|
||||
expect(result.map((r) => r.uri)).toEqual(["a://1", "a://2"]);
|
||||
expect(await changePromise).toEqual(result);
|
||||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1", "a://2"]);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages(
|
||||
{ resources: [resource("a://1")], nextCursor: "c1" },
|
||||
{ resources: [resource("a://2")], nextCursor: "c2" },
|
||||
{ resources: [resource("a://3")] },
|
||||
);
|
||||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((r) => r.uri)).toEqual(["a://1", "a://2", "a://3"]);
|
||||
expect(client.listResources).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("refresh passes setMetadata-supplied metadata to listResources", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "v" });
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
expect(client.listResources).toHaveBeenCalledWith(undefined, { k: "v" });
|
||||
});
|
||||
|
||||
it("refresh argument overrides setMetadata", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "default" });
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh({ k: "override" });
|
||||
expect(client.listResources).toHaveBeenCalledWith(undefined, {
|
||||
k: "override",
|
||||
});
|
||||
});
|
||||
|
||||
it("connect event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
const changePromise = waitForResourcesChange(state);
|
||||
client.dispatchTypedEvent("connect");
|
||||
const next = await changePromise;
|
||||
expect(next.map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
|
||||
it("resourcesListChanged event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({
|
||||
resources: [resource("a://1"), resource("a://2")],
|
||||
});
|
||||
const changePromise = waitForResourcesChange(state);
|
||||
client.dispatchTypedEvent("resourcesListChanged");
|
||||
const next = await changePromise;
|
||||
expect(next.map((r) => r.uri)).toEqual(["a://1", "a://2"]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears resources and dispatches resourcesChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
expect(state.getResources()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForResourcesChange(state);
|
||||
client.setStatus("disconnected");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other (non-disconnected) values does not clear resources", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
client.setStatus("error");
|
||||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listResources.mockImplementation(async () => ({
|
||||
resources: [resource("a://1")],
|
||||
nextCursor: "always",
|
||||
}));
|
||||
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
expect(state.getResources()).toHaveLength(1);
|
||||
|
||||
state.destroy();
|
||||
expect(state.getResources()).toEqual([]);
|
||||
|
||||
client.queueResourcePages({ resources: [resource("a://2")] });
|
||||
client.dispatchTypedEvent("resourcesListChanged");
|
||||
await Promise.resolve();
|
||||
expect(state.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
154
clients/web/src/test/core/mcp/state/managedToolsState.test.ts
Normal file
154
clients/web/src/test/core/mcp/state/managedToolsState.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function tool(name: string): Tool {
|
||||
return { name, inputSchema: { type: "object" } };
|
||||
}
|
||||
|
||||
function waitForToolsChange(state: ManagedToolsState): Promise<Tool[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("toolsChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedToolsState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedToolsState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new ManagedToolsState(client);
|
||||
});
|
||||
|
||||
it("starts with empty tools", () => {
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("getTools returns a defensive copy", () => {
|
||||
const a = state.getTools();
|
||||
const b = state.getTools();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listTools when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches toolsChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a"), tool("b")] });
|
||||
|
||||
const changePromise = waitForToolsChange(state);
|
||||
const result = await state.refresh();
|
||||
|
||||
expect(result.map((t) => t.name)).toEqual(["a", "b"]);
|
||||
expect(await changePromise).toEqual(result);
|
||||
expect(state.getTools().map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages(
|
||||
{ tools: [tool("a")], nextCursor: "c1" },
|
||||
{ tools: [tool("b")], nextCursor: "c2" },
|
||||
{ tools: [tool("c")] },
|
||||
);
|
||||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((t) => t.name)).toEqual(["a", "b", "c"]);
|
||||
expect(client.listTools).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("refresh passes setMetadata-supplied metadata to listTools", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "v" });
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
expect(client.listTools).toHaveBeenCalledWith(undefined, { k: "v" });
|
||||
});
|
||||
|
||||
it("refresh argument overrides setMetadata", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "default" });
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh({ k: "override" });
|
||||
expect(client.listTools).toHaveBeenCalledWith(undefined, { k: "override" });
|
||||
});
|
||||
|
||||
it("connect event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
const changePromise = waitForToolsChange(state);
|
||||
client.dispatchTypedEvent("connect");
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("toolsListChanged event triggers a refresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a"), tool("b")] });
|
||||
const changePromise = waitForToolsChange(state);
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears tools and dispatches toolsChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getTools()).toHaveLength(1);
|
||||
|
||||
const changePromise = waitForToolsChange(state);
|
||||
client.setStatus("disconnected");
|
||||
const next = await changePromise;
|
||||
expect(next).toEqual([]);
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to other (non-disconnected) values does not clear tools", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
client.setStatus("error");
|
||||
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
client.setStatus("connected");
|
||||
// Always returns a non-terminal cursor; refresh should bail out at the cap.
|
||||
client.listTools.mockImplementation(async () => ({
|
||||
tools: [tool("a")],
|
||||
nextCursor: "always",
|
||||
}));
|
||||
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
expect(state.getTools()).toHaveLength(1);
|
||||
|
||||
state.destroy();
|
||||
expect(state.getTools()).toEqual([]);
|
||||
|
||||
// No further refreshes after destroy(): events on client should be ignored.
|
||||
client.queueToolPages({ tools: [tool("b")] });
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
// Give microtasks a chance to flush so a stray refresh would have landed.
|
||||
await Promise.resolve();
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
198
clients/web/src/test/core/mcp/state/messageLogState.test.ts
Normal file
198
clients/web/src/test/core/mcp/state/messageLogState.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types";
|
||||
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function requestEntry(id: number, method = "tools/list"): MessageEntry {
|
||||
return {
|
||||
id: `req-${id}`,
|
||||
timestamp: new Date(2026, 4, 13, 0, 0, 0, id),
|
||||
direction: "request",
|
||||
message: { jsonrpc: "2.0", id, method, params: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function responseEntry(id: number): MessageEntry {
|
||||
return {
|
||||
id: `resp-${id}`,
|
||||
timestamp: new Date(2026, 4, 13, 0, 0, 0, id + 100),
|
||||
direction: "response",
|
||||
message: { jsonrpc: "2.0", id, result: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function notificationEntry(method = "notifications/ping"): MessageEntry {
|
||||
return {
|
||||
id: "notif-1",
|
||||
timestamp: new Date(2026, 4, 13, 0, 0, 1),
|
||||
direction: "notification",
|
||||
message: { jsonrpc: "2.0", method, params: {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe("MessageLogState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: MessageLogState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new MessageLogState(client);
|
||||
});
|
||||
|
||||
it("starts empty and returns defensive copies", () => {
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
expect(state.getMessages()).not.toBe(state.getMessages());
|
||||
});
|
||||
|
||||
it("appends a request entry and dispatches message + messagesChange", () => {
|
||||
const seenSingle: MessageEntry[] = [];
|
||||
const seenFull: MessageEntry[][] = [];
|
||||
state.addEventListener("message", (e) => seenSingle.push(e.detail));
|
||||
state.addEventListener("messagesChange", (e) => seenFull.push(e.detail));
|
||||
|
||||
client.dispatchTypedEvent("message", requestEntry(1));
|
||||
expect(seenSingle).toHaveLength(1);
|
||||
expect(seenFull).toHaveLength(1);
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches a response to its pending request and updates duration", () => {
|
||||
client.dispatchTypedEvent("message", requestEntry(1));
|
||||
const seen: MessageEntry[][] = [];
|
||||
state.addEventListener("messagesChange", (e) => seen.push(e.detail));
|
||||
|
||||
client.dispatchTypedEvent("message", responseEntry(1));
|
||||
const merged = state.getMessages();
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]!.response).toBeDefined();
|
||||
expect(merged[0]!.duration).toBe(100);
|
||||
});
|
||||
|
||||
it("appends an unmatched response when no pending request exists", () => {
|
||||
client.dispatchTypedEvent("message", responseEntry(99));
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
expect(state.getMessages()[0]!.direction).toBe("response");
|
||||
});
|
||||
|
||||
it("appends notification entries", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
expect(state.getMessages()[0]!.direction).toBe("notification");
|
||||
});
|
||||
|
||||
it("trims the oldest entries when maxMessages is exceeded", () => {
|
||||
const small = new MessageLogState(client, { maxMessages: 2 });
|
||||
client.dispatchTypedEvent("message", notificationEntry("a"));
|
||||
client.dispatchTypedEvent("message", notificationEntry("b"));
|
||||
client.dispatchTypedEvent("message", notificationEntry("c"));
|
||||
const seen = small
|
||||
.getMessages()
|
||||
.map((m) => (m.message as { method?: string }).method);
|
||||
expect(seen).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("does not trim when maxMessages is 0", () => {
|
||||
const big = new MessageLogState(client, { maxMessages: 0 });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.dispatchTypedEvent("message", notificationEntry(`m${i}`));
|
||||
}
|
||||
expect(big.getMessages()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("clearMessages with no predicate empties the list and dispatches", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
let changeCount = 0;
|
||||
state.addEventListener("messagesChange", () => changeCount++);
|
||||
state.clearMessages();
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("clearMessages with a predicate filters and dispatches only when changed", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry("a"));
|
||||
client.dispatchTypedEvent("message", notificationEntry("b"));
|
||||
let changeCount = 0;
|
||||
state.addEventListener("messagesChange", () => changeCount++);
|
||||
|
||||
state.clearMessages(
|
||||
(m) => (m.message as { method?: string }).method === "a",
|
||||
);
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
expect(changeCount).toBe(1);
|
||||
|
||||
state.clearMessages(
|
||||
(m) => (m.message as { method?: string }).method === "missing",
|
||||
);
|
||||
expect(changeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("clearMessages drops pending-request bookkeeping for evicted requests", () => {
|
||||
client.dispatchTypedEvent("message", requestEntry(1));
|
||||
state.clearMessages((m) => m.direction === "request");
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
|
||||
// The matching response should now fall through to the unmatched-response
|
||||
// branch (append) instead of mutating an unreachable pending entry.
|
||||
client.dispatchTypedEvent("message", responseEntry(1));
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
expect(state.getMessages()[0]!.direction).toBe("response");
|
||||
expect(state.getMessages()[0]!.duration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearMessages without a predicate also drops all pending bookkeeping", () => {
|
||||
client.dispatchTypedEvent("message", requestEntry(1));
|
||||
state.clearMessages();
|
||||
client.dispatchTypedEvent("message", responseEntry(1));
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
expect(state.getMessages()[0]!.direction).toBe("response");
|
||||
});
|
||||
|
||||
it("getMessages applies the predicate filter", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry("keep"));
|
||||
client.dispatchTypedEvent("message", notificationEntry("drop"));
|
||||
const keep = state.getMessages(
|
||||
(m) => (m.message as { method?: string }).method === "keep",
|
||||
);
|
||||
expect(keep).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears on connect", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
let dispatched = false;
|
||||
state.addEventListener("messagesChange", () => (dispatched = true));
|
||||
client.dispatchTypedEvent("connect");
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
expect(dispatched).toBe(true);
|
||||
});
|
||||
|
||||
it("clears on disconnect (statusChange -> disconnected)", () => {
|
||||
client.setStatus("connected");
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
let dispatched = false;
|
||||
state.addEventListener("messagesChange", () => (dispatched = true));
|
||||
client.setStatus("disconnected");
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
expect(dispatched).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clear on non-disconnected status changes", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
client.setStatus("error");
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
state.destroy();
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
104
clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts
Normal file
104
clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function prompt(name: string): Prompt {
|
||||
return { name };
|
||||
}
|
||||
|
||||
function waitForChange(state: PagedPromptsState): Promise<Prompt[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("promptsChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("PagedPromptsState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: PagedPromptsState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new PagedPromptsState(client);
|
||||
});
|
||||
|
||||
it("starts empty and returns defensive copies", () => {
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
expect(state.getPrompts()).not.toBe(state.getPrompts());
|
||||
});
|
||||
|
||||
it("loadPage no-ops when disconnected", async () => {
|
||||
const result = await state.loadPage();
|
||||
expect(result).toEqual({ prompts: [], nextCursor: undefined });
|
||||
expect(client.listPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loadPage without cursor replaces the aggregated list", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
|
||||
const changePromise = waitForChange(state);
|
||||
const result = await state.loadPage();
|
||||
expect(result.prompts.map((p) => p.name)).toEqual(["a", "b"]);
|
||||
expect(await changePromise).toEqual(result.prompts);
|
||||
});
|
||||
|
||||
it("loadPage with cursor appends to the aggregated list", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages(
|
||||
{ prompts: [prompt("a")], nextCursor: "c1" },
|
||||
{ prompts: [prompt("b")] },
|
||||
);
|
||||
await state.loadPage();
|
||||
await state.loadPage("c1");
|
||||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("loadPage forwards metadata", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.loadPage(undefined, { k: "v" });
|
||||
expect(client.listPrompts).toHaveBeenCalledWith(undefined, { k: "v" });
|
||||
});
|
||||
|
||||
it("clear empties the aggregated list and dispatches", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
state.clear();
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears prompts", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("disconnected");
|
||||
expect(await changePromise).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to non-disconnected values is a no-op", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.loadPage();
|
||||
client.setStatus("error");
|
||||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.loadPage();
|
||||
state.destroy();
|
||||
expect(state.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Task } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { PagedRequestorTasksState } from "@inspector/core/mcp/state/pagedRequestorTasksState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function task(taskId: string, status: Task["status"] = "working"): Task {
|
||||
return {
|
||||
taskId,
|
||||
status,
|
||||
ttl: null,
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
lastUpdatedAt: "2026-05-13T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function waitForChange(state: PagedRequestorTasksState): Promise<Task[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("tasksChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("PagedRequestorTasksState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: PagedRequestorTasksState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new PagedRequestorTasksState(client);
|
||||
});
|
||||
|
||||
it("starts empty and exposes nextCursor", () => {
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
expect(state.getNextCursor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("loadPage no-ops when disconnected", async () => {
|
||||
const result = await state.loadPage();
|
||||
expect(result).toEqual({ tasks: [], nextCursor: undefined });
|
||||
expect(client.listRequestorTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loadPage without cursor replaces tasks and stores nextCursor", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")], nextCursor: "c1" });
|
||||
const changePromise = waitForChange(state);
|
||||
const result = await state.loadPage();
|
||||
expect(result.tasks.map((t) => t.taskId)).toEqual(["t1"]);
|
||||
expect(result.nextCursor).toBe("c1");
|
||||
expect(state.getNextCursor()).toBe("c1");
|
||||
expect(await changePromise).toEqual(result.tasks);
|
||||
});
|
||||
|
||||
it("loadPage with cursor appends tasks and updates nextCursor", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages(
|
||||
{ tasks: [task("t1")], nextCursor: "c1" },
|
||||
{ tasks: [task("t2")] },
|
||||
);
|
||||
await state.loadPage();
|
||||
await state.loadPage("c1");
|
||||
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1", "t2"]);
|
||||
expect(state.getNextCursor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clear empties tasks, resets nextCursor, and dispatches", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")], nextCursor: "c1" });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
state.clear();
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
expect(state.getNextCursor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tasksListChanged refetches the first page", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("tasksListChanged");
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.taskId)).toEqual(["t1", "t2"]);
|
||||
});
|
||||
|
||||
it("taskStatusChange merges a known task", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1", "working")] });
|
||||
await state.loadPage();
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("taskStatusChange", {
|
||||
taskId: "t1",
|
||||
task: task("t1", "completed"),
|
||||
});
|
||||
const next = await changePromise;
|
||||
expect(next[0]!.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("requestorTaskUpdated inserts an unknown task at the front", async () => {
|
||||
client.setStatus("connected");
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("requestorTaskUpdated", {
|
||||
taskId: "tx",
|
||||
task: {
|
||||
taskId: "tx",
|
||||
status: "working",
|
||||
ttl: null,
|
||||
lastUpdatedAt: "2026-05-13T03:00:00Z",
|
||||
},
|
||||
});
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.taskId)).toEqual(["tx"]);
|
||||
expect(next[0]!.createdAt).toBe("2026-05-13T03:00:00Z");
|
||||
});
|
||||
|
||||
it("taskCancelled flips status when the task is known", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1", "working")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.dispatchTypedEvent("taskCancelled", { taskId: "t1" });
|
||||
const next = await changePromise;
|
||||
expect(next[0]!.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("taskCancelled is a no-op when the task is unknown", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.loadPage();
|
||||
let dispatched = false;
|
||||
state.addEventListener("tasksChange", () => {
|
||||
dispatched = true;
|
||||
});
|
||||
client.dispatchTypedEvent("taskCancelled", { taskId: "missing" });
|
||||
expect(dispatched).toBe(false);
|
||||
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears tasks and nextCursor", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")], nextCursor: "c1" });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("disconnected");
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getNextCursor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.loadPage();
|
||||
state.destroy();
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
expect(state.getNextCursor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { PagedResourceTemplatesState } from "@inspector/core/mcp/state/pagedResourceTemplatesState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function template(name: string): ResourceTemplate {
|
||||
return { uriTemplate: `tpl://{${name}}`, name };
|
||||
}
|
||||
|
||||
function waitForChange(
|
||||
state: PagedResourceTemplatesState,
|
||||
): Promise<ResourceTemplate[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener(
|
||||
"resourceTemplatesChange",
|
||||
(e) => resolve(e.detail),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("PagedResourceTemplatesState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: PagedResourceTemplatesState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new PagedResourceTemplatesState(client);
|
||||
});
|
||||
|
||||
it("starts empty and returns defensive copies", () => {
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
expect(state.getResourceTemplates()).not.toBe(state.getResourceTemplates());
|
||||
});
|
||||
|
||||
it("loadPage no-ops when disconnected", async () => {
|
||||
const result = await state.loadPage();
|
||||
expect(result).toEqual({ resourceTemplates: [], nextCursor: undefined });
|
||||
expect(client.listResourceTemplates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loadPage without cursor replaces the aggregated list", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({
|
||||
resourceTemplates: [template("a"), template("b")],
|
||||
});
|
||||
const changePromise = waitForChange(state);
|
||||
const result = await state.loadPage();
|
||||
expect(result.resourceTemplates.map((t) => t.name)).toEqual(["a", "b"]);
|
||||
expect(await changePromise).toEqual(result.resourceTemplates);
|
||||
});
|
||||
|
||||
it("loadPage with cursor appends to the aggregated list", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages(
|
||||
{ resourceTemplates: [template("a")], nextCursor: "c1" },
|
||||
{ resourceTemplates: [template("b")] },
|
||||
);
|
||||
await state.loadPage();
|
||||
await state.loadPage("c1");
|
||||
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("loadPage forwards metadata", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.loadPage(undefined, { k: "v" });
|
||||
expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, {
|
||||
k: "v",
|
||||
});
|
||||
});
|
||||
|
||||
it("clear empties the aggregated list and dispatches", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
state.clear();
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears templates", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("disconnected");
|
||||
expect(await changePromise).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to non-disconnected values is a no-op", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.loadPage();
|
||||
client.setStatus("error");
|
||||
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.loadPage();
|
||||
state.destroy();
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
106
clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts
Normal file
106
clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function resource(uri: string): Resource {
|
||||
return { uri, name: uri };
|
||||
}
|
||||
|
||||
function waitForChange(state: PagedResourcesState): Promise<Resource[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("resourcesChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("PagedResourcesState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: PagedResourcesState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new PagedResourcesState(client);
|
||||
});
|
||||
|
||||
it("starts empty and returns defensive copies", () => {
|
||||
expect(state.getResources()).toEqual([]);
|
||||
expect(state.getResources()).not.toBe(state.getResources());
|
||||
});
|
||||
|
||||
it("loadPage no-ops when disconnected", async () => {
|
||||
const result = await state.loadPage();
|
||||
expect(result).toEqual({ resources: [], nextCursor: undefined });
|
||||
expect(client.listResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loadPage without cursor replaces the aggregated list", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({
|
||||
resources: [resource("a://1"), resource("a://2")],
|
||||
});
|
||||
const changePromise = waitForChange(state);
|
||||
const result = await state.loadPage();
|
||||
expect(result.resources.map((r) => r.uri)).toEqual(["a://1", "a://2"]);
|
||||
expect(await changePromise).toEqual(result.resources);
|
||||
});
|
||||
|
||||
it("loadPage with cursor appends to the aggregated list", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages(
|
||||
{ resources: [resource("a://1")], nextCursor: "c1" },
|
||||
{ resources: [resource("a://2")] },
|
||||
);
|
||||
await state.loadPage();
|
||||
await state.loadPage("c1");
|
||||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1", "a://2"]);
|
||||
});
|
||||
|
||||
it("loadPage forwards metadata", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.loadPage(undefined, { k: "v" });
|
||||
expect(client.listResources).toHaveBeenCalledWith(undefined, { k: "v" });
|
||||
});
|
||||
|
||||
it("clear empties the aggregated list and dispatches", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
state.clear();
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears resources", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("disconnected");
|
||||
expect(await changePromise).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to non-disconnected values is a no-op", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.loadPage();
|
||||
client.setStatus("error");
|
||||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.loadPage();
|
||||
state.destroy();
|
||||
expect(state.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
113
clients/web/src/test/core/mcp/state/pagedToolsState.test.ts
Normal file
113
clients/web/src/test/core/mcp/state/pagedToolsState.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { PagedToolsState } from "@inspector/core/mcp/state/pagedToolsState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function tool(name: string): Tool {
|
||||
return { name, inputSchema: { type: "object" } };
|
||||
}
|
||||
|
||||
function waitForChange(state: PagedToolsState): Promise<Tool[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("toolsChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("PagedToolsState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: PagedToolsState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new PagedToolsState(client);
|
||||
});
|
||||
|
||||
it("starts empty and returns defensive copies", () => {
|
||||
expect(state.getTools()).toEqual([]);
|
||||
expect(state.getTools()).not.toBe(state.getTools());
|
||||
});
|
||||
|
||||
it("loadPage no-ops when disconnected", async () => {
|
||||
const result = await state.loadPage();
|
||||
expect(result).toEqual({ tools: [], nextCursor: undefined });
|
||||
expect(client.listTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loadPage without cursor replaces the aggregated list", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a"), tool("b")] });
|
||||
const changePromise = waitForChange(state);
|
||||
const result = await state.loadPage();
|
||||
expect(result.tools.map((t) => t.name)).toEqual(["a", "b"]);
|
||||
expect(await changePromise).toEqual(result.tools);
|
||||
});
|
||||
|
||||
it("loadPage with cursor appends to the aggregated list", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages(
|
||||
{ tools: [tool("a")], nextCursor: "c1" },
|
||||
{ tools: [tool("b")] },
|
||||
);
|
||||
const first = await state.loadPage();
|
||||
expect(first.nextCursor).toBe("c1");
|
||||
const second = await state.loadPage("c1");
|
||||
expect(second.nextCursor).toBeUndefined();
|
||||
expect(state.getTools().map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("clear empties the aggregated list and dispatches", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
state.clear();
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears tools", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.loadPage();
|
||||
const changePromise = waitForChange(state);
|
||||
client.setStatus("disconnected");
|
||||
expect(await changePromise).toEqual([]);
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("statusChange to non-disconnected values is a no-op", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.loadPage();
|
||||
client.setStatus("error");
|
||||
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("does not subscribe to toolsListChanged (paged is caller-driven)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
await Promise.resolve();
|
||||
expect(state.getTools()).toEqual([]);
|
||||
expect(client.listTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.loadPage();
|
||||
state.destroy();
|
||||
expect(state.getTools()).toEqual([]);
|
||||
|
||||
client.setStatus("disconnected");
|
||||
await Promise.resolve();
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
90
clients/web/src/test/core/mcp/state/stderrLogState.test.ts
Normal file
90
clients/web/src/test/core/mcp/state/stderrLogState.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { StderrLogEntry } from "@inspector/core/mcp/types";
|
||||
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
function entry(message: string): StderrLogEntry {
|
||||
return { message, timestamp: new Date(2026, 4, 13) };
|
||||
}
|
||||
|
||||
describe("StderrLogState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: StderrLogState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new StderrLogState(client);
|
||||
});
|
||||
|
||||
it("starts empty and returns defensive copies", () => {
|
||||
expect(state.getStderrLogs()).toEqual([]);
|
||||
expect(state.getStderrLogs()).not.toBe(state.getStderrLogs());
|
||||
});
|
||||
|
||||
it("appends entries and dispatches stderrLog + stderrLogsChange", () => {
|
||||
const seenSingle: StderrLogEntry[] = [];
|
||||
const seenFull: StderrLogEntry[][] = [];
|
||||
state.addEventListener("stderrLog", (e) => seenSingle.push(e.detail));
|
||||
state.addEventListener("stderrLogsChange", (e) => seenFull.push(e.detail));
|
||||
|
||||
client.dispatchTypedEvent("stderrLog", entry("a"));
|
||||
client.dispatchTypedEvent("stderrLog", entry("b"));
|
||||
|
||||
expect(state.getStderrLogs().map((e) => e.message)).toEqual(["a", "b"]);
|
||||
expect(seenSingle).toHaveLength(2);
|
||||
expect(seenFull).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("trims the oldest entries when maxStderrLogEvents is exceeded", () => {
|
||||
const small = new StderrLogState(client, { maxStderrLogEvents: 2 });
|
||||
client.dispatchTypedEvent("stderrLog", entry("a"));
|
||||
client.dispatchTypedEvent("stderrLog", entry("b"));
|
||||
client.dispatchTypedEvent("stderrLog", entry("c"));
|
||||
expect(small.getStderrLogs().map((e) => e.message)).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("does not trim when maxStderrLogEvents is 0", () => {
|
||||
const big = new StderrLogState(client, { maxStderrLogEvents: 0 });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.dispatchTypedEvent("stderrLog", entry(`m${i}`));
|
||||
}
|
||||
expect(big.getStderrLogs()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("clearStderrLogs dispatches when the list was non-empty", () => {
|
||||
client.dispatchTypedEvent("stderrLog", entry("a"));
|
||||
let dispatched = false;
|
||||
state.addEventListener("stderrLogsChange", () => (dispatched = true));
|
||||
state.clearStderrLogs();
|
||||
expect(dispatched).toBe(true);
|
||||
expect(state.getStderrLogs()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clearStderrLogs is a no-op when the list is empty", () => {
|
||||
let dispatched = false;
|
||||
state.addEventListener("stderrLogsChange", () => (dispatched = true));
|
||||
state.clearStderrLogs();
|
||||
expect(dispatched).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT clear on connect or disconnect", () => {
|
||||
client.dispatchTypedEvent("stderrLog", entry("a"));
|
||||
client.dispatchTypedEvent("connect");
|
||||
expect(state.getStderrLogs().map((e) => e.message)).toEqual(["a"]);
|
||||
client.setStatus("disconnected");
|
||||
expect(state.getStderrLogs().map((e) => e.message)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("destroy stops listening and clears state", () => {
|
||||
client.dispatchTypedEvent("stderrLog", entry("a"));
|
||||
state.destroy();
|
||||
expect(state.getStderrLogs()).toEqual([]);
|
||||
client.dispatchTypedEvent("stderrLog", entry("b"));
|
||||
expect(state.getStderrLogs()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy is idempotent", () => {
|
||||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
67
clients/web/src/test/core/react/useFetchRequestLog.test.tsx
Normal file
67
clients/web/src/test/core/react/useFetchRequestLog.test.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { FetchRequestEntry } from "@inspector/core/mcp/types";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState";
|
||||
import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog";
|
||||
|
||||
function entry(id: string): FetchRequestEntry {
|
||||
return {
|
||||
id,
|
||||
timestamp: new Date(2026, 4, 13),
|
||||
method: "GET",
|
||||
url: `https://x/${id}`,
|
||||
requestHeaders: {},
|
||||
category: "transport",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useFetchRequestLog", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: FetchRequestLogState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new FetchRequestLogState(client);
|
||||
});
|
||||
|
||||
it("returns the initial snapshot from the state", () => {
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
const { result } = renderHook(() => useFetchRequestLog(state));
|
||||
expect(result.current.fetchRequests.map((e) => e.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("returns empty when state is null", () => {
|
||||
const { result } = renderHook(() => useFetchRequestLog(null));
|
||||
expect(result.current.fetchRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates when state dispatches fetchRequestsChange", async () => {
|
||||
const { result } = renderHook(() => useFetchRequestLog(state));
|
||||
act(() => {
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.fetchRequests).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets to empty when the state prop becomes null", async () => {
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: FetchRequestLogState | null }) => useFetchRequestLog(s),
|
||||
{ initialProps: { s: state as FetchRequestLogState | null } },
|
||||
);
|
||||
rerender({ s: null });
|
||||
await waitFor(() => {
|
||||
expect(result.current.fetchRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes on unmount", () => {
|
||||
const { result, unmount } = renderHook(() => useFetchRequestLog(state));
|
||||
unmount();
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
expect(result.current.fetchRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
149
clients/web/src/test/core/react/useInspectorClient.test.tsx
Normal file
149
clients/web/src/test/core/react/useInspectorClient.test.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type {
|
||||
Implementation,
|
||||
ServerCapabilities,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import { useInspectorClient } from "@inspector/core/react/useInspectorClient";
|
||||
import type { InspectorClientProtocol } from "@inspector/core/mcp/inspectorClientProtocol";
|
||||
|
||||
const CAPABILITIES: ServerCapabilities = { tools: { listChanged: true } };
|
||||
const SERVER_INFO: Implementation = { name: "srv", version: "1.0" };
|
||||
|
||||
describe("useInspectorClient", () => {
|
||||
it("returns initial accessors from the client", () => {
|
||||
const client = new FakeInspectorClient({
|
||||
status: "connected",
|
||||
capabilities: CAPABILITIES,
|
||||
serverInfo: SERVER_INFO,
|
||||
instructions: "hello",
|
||||
});
|
||||
const { result } = renderHook(() => useInspectorClient(client));
|
||||
expect(result.current.status).toBe("connected");
|
||||
expect(result.current.capabilities).toEqual(CAPABILITIES);
|
||||
expect(result.current.serverInfo).toEqual(SERVER_INFO);
|
||||
expect(result.current.instructions).toBe("hello");
|
||||
expect(result.current.appRendererClient).toBeNull();
|
||||
});
|
||||
|
||||
it("returns disconnected defaults when client is null", () => {
|
||||
const { result } = renderHook(() => useInspectorClient(null));
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
expect(result.current.capabilities).toBeUndefined();
|
||||
expect(result.current.serverInfo).toBeUndefined();
|
||||
expect(result.current.instructions).toBeUndefined();
|
||||
expect(result.current.appRendererClient).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes to statusChange and updates", () => {
|
||||
const client = new FakeInspectorClient();
|
||||
const { result } = renderHook(() => useInspectorClient(client));
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
act(() => {
|
||||
client.setStatus("connecting");
|
||||
});
|
||||
expect(result.current.status).toBe("connecting");
|
||||
act(() => {
|
||||
client.setStatus("connected");
|
||||
});
|
||||
expect(result.current.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("subscribes to capabilities/serverInfo/instructions changes", () => {
|
||||
const client = new FakeInspectorClient();
|
||||
const { result } = renderHook(() => useInspectorClient(client));
|
||||
act(() => {
|
||||
client.setCapabilities(CAPABILITIES);
|
||||
client.setServerInfo(SERVER_INFO);
|
||||
client.setInstructions("after");
|
||||
});
|
||||
expect(result.current.capabilities).toEqual(CAPABILITIES);
|
||||
expect(result.current.serverInfo).toEqual(SERVER_INFO);
|
||||
expect(result.current.instructions).toBe("after");
|
||||
});
|
||||
|
||||
it("connect() and disconnect() proxy to the client and update status", async () => {
|
||||
const client = new FakeInspectorClient();
|
||||
const { result } = renderHook(() => useInspectorClient(client));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
expect(result.current.status).toBe("connected");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.disconnect();
|
||||
});
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("connect() and disconnect() are no-ops when client is null", async () => {
|
||||
const { result } = renderHook(() => useInspectorClient(null));
|
||||
await expect(result.current.connect()).resolves.toBeUndefined();
|
||||
await expect(result.current.disconnect()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("resets to defaults when client becomes null", () => {
|
||||
const client = new FakeInspectorClient({
|
||||
status: "connected",
|
||||
capabilities: CAPABILITIES,
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ c }: { c: InspectorClientProtocol | null }) => useInspectorClient(c),
|
||||
{ initialProps: { c: client as InspectorClientProtocol | null } },
|
||||
);
|
||||
expect(result.current.status).toBe("connected");
|
||||
rerender({ c: null });
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
expect(result.current.capabilities).toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-subscribes when the client prop changes", () => {
|
||||
const a = new FakeInspectorClient({ status: "connected" });
|
||||
const b = new FakeInspectorClient({ status: "disconnected" });
|
||||
const { result, rerender } = renderHook(
|
||||
({ c }: { c: InspectorClientProtocol }) => useInspectorClient(c),
|
||||
{ initialProps: { c: a as InspectorClientProtocol } },
|
||||
);
|
||||
expect(result.current.status).toBe("connected");
|
||||
|
||||
rerender({ c: b });
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
|
||||
// After rerender, events on the OLD client should be ignored.
|
||||
act(() => {
|
||||
a.setStatus("error");
|
||||
});
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
|
||||
// Events on the NEW client should be observed.
|
||||
act(() => {
|
||||
b.setStatus("connecting");
|
||||
});
|
||||
expect(result.current.status).toBe("connecting");
|
||||
});
|
||||
|
||||
it("unsubscribes on unmount", () => {
|
||||
const client = new FakeInspectorClient({ status: "connected" });
|
||||
const { result, unmount } = renderHook(() => useInspectorClient(client));
|
||||
unmount();
|
||||
act(() => {
|
||||
client.setStatus("error");
|
||||
});
|
||||
// The hook is unmounted; result.current still reads the last rendered
|
||||
// value ("connected") rather than the post-unmount event.
|
||||
expect(result.current.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("reads appRendererClient lazily from the client on each render", () => {
|
||||
const client = new FakeInspectorClient({ status: "connected" });
|
||||
const { result, rerender } = renderHook(() => useInspectorClient(client));
|
||||
expect(result.current.appRendererClient).toBeNull();
|
||||
|
||||
const sentinel = { iam: "renderer" };
|
||||
client.setAppRendererClient(sentinel);
|
||||
rerender();
|
||||
expect(result.current.appRendererClient).toBe(sentinel);
|
||||
});
|
||||
});
|
||||
102
clients/web/src/test/core/react/useManagedPrompts.test.tsx
Normal file
102
clients/web/src/test/core/react/useManagedPrompts.test.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState";
|
||||
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts";
|
||||
|
||||
function prompt(name: string): Prompt {
|
||||
return { name };
|
||||
}
|
||||
|
||||
describe("useManagedPrompts", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedPromptsState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient({ status: "connected" });
|
||||
state = new ManagedPromptsState(client);
|
||||
});
|
||||
|
||||
it("returns the initial prompts snapshot from the state", async () => {
|
||||
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
|
||||
await state.refresh();
|
||||
|
||||
const { result } = renderHook(() => useManagedPrompts(client, state));
|
||||
expect(result.current.prompts.map((p) => p.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns empty prompts when state is null", () => {
|
||||
const { result } = renderHook(() => useManagedPrompts(client, null));
|
||||
expect(result.current.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates when state dispatches promptsChange", async () => {
|
||||
const { result } = renderHook(() => useManagedPrompts(client, state));
|
||||
expect(result.current.prompts).toEqual([]);
|
||||
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await act(async () => {
|
||||
await state.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.prompts.map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() calls through to state and returns the next prompts", async () => {
|
||||
client.queuePromptPages({ prompts: [prompt("x")] });
|
||||
const { result } = renderHook(() => useManagedPrompts(client, state));
|
||||
|
||||
let next: Prompt[] = [];
|
||||
await act(async () => {
|
||||
next = await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(next.map((p) => p.name)).toEqual(["x"]);
|
||||
expect(result.current.prompts.map((p) => p.name)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("refresh() returns [] when state or client is null", async () => {
|
||||
const { result } = renderHook(() => useManagedPrompts(null, state));
|
||||
await expect(result.current.refresh()).resolves.toEqual([]);
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useManagedPrompts(client, null),
|
||||
);
|
||||
await expect(result2.current.refresh()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("resets to empty prompts when the state prop becomes null", async () => {
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: ManagedPromptsState | null }) =>
|
||||
useManagedPrompts(client, s),
|
||||
{ initialProps: { s: state as ManagedPromptsState | null } },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.prompts.map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
rerender({ s: null });
|
||||
await waitFor(() => {
|
||||
expect(result.current.prompts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes from the state on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useManagedPrompts(client, state),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
|
||||
expect(result.current.prompts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { Task } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState";
|
||||
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks";
|
||||
|
||||
function task(taskId: string, status: Task["status"] = "working"): Task {
|
||||
return {
|
||||
taskId,
|
||||
status,
|
||||
ttl: null,
|
||||
createdAt: "2026-05-13T00:00:00Z",
|
||||
lastUpdatedAt: "2026-05-13T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useManagedRequestorTasks", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedRequestorTasksState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient({ status: "connected" });
|
||||
state = new ManagedRequestorTasksState(client);
|
||||
});
|
||||
|
||||
it("returns the initial tasks snapshot from the state", async () => {
|
||||
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
|
||||
await state.refresh();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useManagedRequestorTasks(client, state),
|
||||
);
|
||||
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1", "t2"]);
|
||||
});
|
||||
|
||||
it("returns empty tasks when state is null", () => {
|
||||
const { result } = renderHook(() => useManagedRequestorTasks(client, null));
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates when state dispatches tasksChange", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useManagedRequestorTasks(client, state),
|
||||
);
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await act(async () => {
|
||||
await state.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() calls through to state and returns the next tasks", async () => {
|
||||
client.queueTaskPages({ tasks: [task("tx")] });
|
||||
const { result } = renderHook(() =>
|
||||
useManagedRequestorTasks(client, state),
|
||||
);
|
||||
|
||||
let next: Task[] = [];
|
||||
await act(async () => {
|
||||
next = await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(next.map((t) => t.taskId)).toEqual(["tx"]);
|
||||
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["tx"]);
|
||||
});
|
||||
|
||||
it("refresh() returns [] when state or client is null", async () => {
|
||||
const { result } = renderHook(() => useManagedRequestorTasks(null, state));
|
||||
await expect(result.current.refresh()).resolves.toEqual([]);
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useManagedRequestorTasks(client, null),
|
||||
);
|
||||
await expect(result2.current.refresh()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("resets to empty tasks when the state prop becomes null", async () => {
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.refresh();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: ManagedRequestorTasksState | null }) =>
|
||||
useManagedRequestorTasks(client, s),
|
||||
{ initialProps: { s: state as ManagedRequestorTasksState | null } },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
rerender({ s: null });
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes from the state on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useManagedRequestorTasks(client, state),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
client.queueTaskPages({ tasks: [task("t1")] });
|
||||
await state.refresh();
|
||||
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState";
|
||||
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates";
|
||||
|
||||
function template(name: string): ResourceTemplate {
|
||||
return { uriTemplate: `tpl://{${name}}`, name };
|
||||
}
|
||||
|
||||
describe("useManagedResourceTemplates", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedResourceTemplatesState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient({ status: "connected" });
|
||||
state = new ManagedResourceTemplatesState(client);
|
||||
});
|
||||
|
||||
it("returns the initial templates snapshot from the state", async () => {
|
||||
client.queueResourceTemplatePages({
|
||||
resourceTemplates: [template("a"), template("b")],
|
||||
});
|
||||
await state.refresh();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useManagedResourceTemplates(client, state),
|
||||
);
|
||||
expect(result.current.resourceTemplates.map((t) => t.name)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty templates when state is null", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useManagedResourceTemplates(client, null),
|
||||
);
|
||||
expect(result.current.resourceTemplates).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates when state dispatches resourceTemplatesChange", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useManagedResourceTemplates(client, state),
|
||||
);
|
||||
expect(result.current.resourceTemplates).toEqual([]);
|
||||
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await act(async () => {
|
||||
await state.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.resourceTemplates.map((t) => t.name)).toEqual([
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() calls through to state and returns the next templates", async () => {
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("x")] });
|
||||
const { result } = renderHook(() =>
|
||||
useManagedResourceTemplates(client, state),
|
||||
);
|
||||
|
||||
let next: ResourceTemplate[] = [];
|
||||
await act(async () => {
|
||||
next = await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(next.map((t) => t.name)).toEqual(["x"]);
|
||||
expect(result.current.resourceTemplates.map((t) => t.name)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("refresh() returns [] when state or client is null", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useManagedResourceTemplates(null, state),
|
||||
);
|
||||
await expect(result.current.refresh()).resolves.toEqual([]);
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useManagedResourceTemplates(client, null),
|
||||
);
|
||||
await expect(result2.current.refresh()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("resets to empty when state becomes null", async () => {
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: ManagedResourceTemplatesState | null }) =>
|
||||
useManagedResourceTemplates(client, s),
|
||||
{ initialProps: { s: state as ManagedResourceTemplatesState | null } },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.resourceTemplates.map((t) => t.name)).toEqual([
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
rerender({ s: null });
|
||||
await waitFor(() => {
|
||||
expect(result.current.resourceTemplates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes from the state on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useManagedResourceTemplates(client, state),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
|
||||
expect(result.current.resourceTemplates).toEqual([]);
|
||||
});
|
||||
});
|
||||
107
clients/web/src/test/core/react/useManagedResources.test.tsx
Normal file
107
clients/web/src/test/core/react/useManagedResources.test.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState";
|
||||
import { useManagedResources } from "@inspector/core/react/useManagedResources";
|
||||
|
||||
function resource(uri: string): Resource {
|
||||
return { uri, name: uri };
|
||||
}
|
||||
|
||||
describe("useManagedResources", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedResourcesState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient({ status: "connected" });
|
||||
state = new ManagedResourcesState(client);
|
||||
});
|
||||
|
||||
it("returns the initial resources snapshot from the state", async () => {
|
||||
client.queueResourcePages({
|
||||
resources: [resource("a://1"), resource("a://2")],
|
||||
});
|
||||
await state.refresh();
|
||||
|
||||
const { result } = renderHook(() => useManagedResources(client, state));
|
||||
expect(result.current.resources.map((r) => r.uri)).toEqual([
|
||||
"a://1",
|
||||
"a://2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty resources when state is null", () => {
|
||||
const { result } = renderHook(() => useManagedResources(client, null));
|
||||
expect(result.current.resources).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates when state dispatches resourcesChange", async () => {
|
||||
const { result } = renderHook(() => useManagedResources(client, state));
|
||||
expect(result.current.resources).toEqual([]);
|
||||
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await act(async () => {
|
||||
await state.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.resources.map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() calls through to state and returns the next resources", async () => {
|
||||
client.queueResourcePages({ resources: [resource("x://1")] });
|
||||
const { result } = renderHook(() => useManagedResources(client, state));
|
||||
|
||||
let next: Resource[] = [];
|
||||
await act(async () => {
|
||||
next = await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(next.map((r) => r.uri)).toEqual(["x://1"]);
|
||||
expect(result.current.resources.map((r) => r.uri)).toEqual(["x://1"]);
|
||||
});
|
||||
|
||||
it("refresh() returns [] when state or client is null", async () => {
|
||||
const { result } = renderHook(() => useManagedResources(null, state));
|
||||
await expect(result.current.refresh()).resolves.toEqual([]);
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useManagedResources(client, null),
|
||||
);
|
||||
await expect(result2.current.refresh()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("resets to empty resources when the state prop becomes null", async () => {
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: ManagedResourcesState | null }) =>
|
||||
useManagedResources(client, s),
|
||||
{ initialProps: { s: state as ManagedResourcesState | null } },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.resources.map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
|
||||
rerender({ s: null });
|
||||
await waitFor(() => {
|
||||
expect(result.current.resources).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes from the state on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useManagedResources(client, state),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
|
||||
expect(result.current.resources).toEqual([]);
|
||||
});
|
||||
});
|
||||
101
clients/web/src/test/core/react/useManagedTools.test.tsx
Normal file
101
clients/web/src/test/core/react/useManagedTools.test.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
|
||||
import { useManagedTools } from "@inspector/core/react/useManagedTools";
|
||||
|
||||
function tool(name: string): Tool {
|
||||
return { name, inputSchema: { type: "object" } };
|
||||
}
|
||||
|
||||
describe("useManagedTools", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedToolsState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient({ status: "connected" });
|
||||
state = new ManagedToolsState(client);
|
||||
});
|
||||
|
||||
it("returns the initial tools snapshot from the state", async () => {
|
||||
client.queueToolPages({ tools: [tool("a"), tool("b")] });
|
||||
await state.refresh();
|
||||
|
||||
const { result } = renderHook(() => useManagedTools(client, state));
|
||||
expect(result.current.tools.map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns empty tools when state is null", () => {
|
||||
const { result } = renderHook(() => useManagedTools(client, null));
|
||||
expect(result.current.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates when state dispatches toolsChange", async () => {
|
||||
const { result } = renderHook(() => useManagedTools(client, state));
|
||||
expect(result.current.tools).toEqual([]);
|
||||
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await act(async () => {
|
||||
await state.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tools.map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() calls through to state and returns the next tools", async () => {
|
||||
client.queueToolPages({ tools: [tool("x")] });
|
||||
const { result } = renderHook(() => useManagedTools(client, state));
|
||||
|
||||
let next: Tool[] = [];
|
||||
await act(async () => {
|
||||
next = await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(next.map((t) => t.name)).toEqual(["x"]);
|
||||
expect(result.current.tools.map((t) => t.name)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("refresh() returns [] when state or client is null", async () => {
|
||||
const { result } = renderHook(() => useManagedTools(null, state));
|
||||
await expect(result.current.refresh()).resolves.toEqual([]);
|
||||
|
||||
const { result: result2 } = renderHook(() => useManagedTools(client, null));
|
||||
await expect(result2.current.refresh()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("resets to empty tools when the state prop becomes null", async () => {
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: ManagedToolsState | null }) => useManagedTools(client, s),
|
||||
{ initialProps: { s: state as ManagedToolsState | null } },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.tools.map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
rerender({ s: null });
|
||||
await waitFor(() => {
|
||||
expect(result.current.tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes from the state on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useManagedTools(client, state),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
|
||||
// After unmount the hook should not be receiving updates — the last
|
||||
// observed value is the empty snapshot captured before the refresh.
|
||||
expect(result.current.tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue