Thread cacheMode through managed list refresh via SDK cache-aware verbs (#1721) (#1723)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* Thread cacheMode through managed list refresh via SDK cache-aware verbs (#1721) Manual/auto refresh of tools/resources/prompts now forces a cache-bypassing round trip on modern servers, delivering the raw-request cacheMode control deferred from #1628. The InspectorClient list/read path uses the SDK's single-page `client.request` verbs (for pagination debugging), which never consult the SDK response cache — so `cacheMode` was inert there. This adds a separate aggregate path that routes through the SDK's cache-aware high-level verbs, where `cacheMode` is honored: - InspectorClient: new `listAllTools/Prompts/Resources/ResourceTemplates( { cacheMode, metadata })` that call `client.listTools()` etc. (all-page aggregate + SEP-2243 x-mcp-header exclusion + cache consult/write). A shared `getCacheableRequestOptions` / `aggregateListParams` back all four. - InspectorClientProtocol: the four `listAll*` methods added. - ManagedListState: delegates page walking to the SDK verb (config `fetchPage` loop → `fetchAll`); `refresh(metadata?, cacheMode?)` threads cacheMode. A `list_changed` auto-refresh bypasses the cache (`cacheMode: "refresh"`). - Managed hooks: user-initiated `refresh()` passes `cacheMode: "refresh"`, so the existing per-tab Refresh buttons become genuinely cache-bypassing — no App wiring change. Connect-time load stays default (`'use'`). The single-page `listTools(cursor)` primitive is unchanged; the paged hooks still surface per-page `nextCursor`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Single-page list pagination: sidebar toggle + server setting (#1721) Delivers the deferred single-page fetch UI: Tools/Resources/Prompts can be loaded one page at a time instead of auto-aggregating every page — a defensive option for servers with very large lists. - New `singlePageLists` server setting (persisted, server-wide), plumbed through ServerConfig/InspectorServerSettings, serverList convert/default, the node + remote settings paths, and the Server Settings form ("Fetch Lists One Page at a Time"). - New `ListPaginationControls` element (a "Single page" switch + "Load next page" button + "N pages loaded" status) rendered in each list sidebar; the toggle edits the setting live (optimistic) and persists it. - The paged state stores (PagedTools/Prompts/ResourcesState) now own loading: they auto-load page 1 on connect in single-page mode and track nextCursor + page count as observable state; the managed stores skip their all-page walk in single-page mode (so nothing is over-fetched) while still lighting the list-changed indicator. `usePaginatedList` selects the display source and pagination controls per mode — pure, no data-loading in React effects. - Refresh is mode-aware: reload page 1 in single-page mode, refetch the whole aggregate otherwise. Sidebars widened 340→360 so the controls don't crowd list entries. - Test server: `test-servers/configs/pagination-http.json` (12 tools/resources/ prompts, page size 4 → 3 pages each) exercises the flow end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Add pr-screenshots proving single-page pagination (#1721) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Address @claude review: template load, double-click guard, auth recovery (#1721) - Fix: resource templates never loaded on connect in single-page mode. The connect-time all-page skip now applies only to lists with a paged counterpart (new `deferInSinglePage` flag on ManagedListConfig; false for resource templates), so templates still aggregate on connect regardless of the setting. - Guard `PagedTools/Prompts/ResourcesState.loadPage` against a concurrent load (fast double-click on "Load next page") so the same page can't be appended twice; the in-flight load owns the cursor and the second call is a no-op. - Thread ambient auth recovery through the single-page refresh, Load-next-page, and mode-switch loads (they previously bypassed it) — usePaginatedList's onRefresh/onLoadMore now return their promises so App can wrap them. - Drop the misleading "aggregated list" wording from the paged-store docs. Tests added for each; npm run coverage green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Review round 2: revert optimistic toggle when persisting the setting fails (#1721) Non-blocking review follow-up. If updateServerSettings rejects, the optimistic `singlePageListsOverride` is now cleared and the live client setting rolled back (the effect only clears the override when the persisted value changes, which doesn't happen on failure), so the UI and client reflect what's on disk rather than the failed edit. Added an App test for the revert path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Fix single-page Refresh not clearing list-changed indicator + UI polish (#1721) Round-3 review fix (blocking): in single-page mode a `list_changed` lights the list-changed indicator, but the single-page Refresh reloads page 1 of the paged store and bypassed `useManaged*.refresh()`, so the indicator was never cleared. Expose `clearListChanged` from useManagedTools/Prompts/Resources and call it in the single-page branch of onRefreshTools/Prompts/Resources. Tests added. UI polish (user feedback on the sidebar control): - Rename the switch label "Single page" → "Paginated". - Put "Load next page" on the same row as the switch, space-between (switch fully left, button fully right). - Move the "N pages loaded" status to its own line below the row, centered. - Hide the whole control once the list is known to be a single page (single-page mode, page 1 loaded, no nextCursor) — nothing to paginate. pr-screenshots refreshed to the new layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Rename the singlePageLists setting to paginatedLists for clarity (#1721) `singlePageLists` read backwards from the "Paginated" switch label (it sounds like paginated=false). Rename the server setting to `paginatedLists` with the same value meaning — true = paginated/one-page-at-a-time, false = aggregate-all — and follow through on the related identifiers for consistency: - Setting field `paginatedLists` across ServerConfig / InspectorServerSettings, serverList convert/default, node + remote settings paths, and the form/modal callbacks (onPaginatedListsChange). - `usePaginatedList` / `ListPaginationControls` prop `singlePage` → `paginated`, `onSinglePageChange` → `onPaginatedChange`. - `ManagedListConfig.deferInSinglePage` → `deferWhenPaginated`. - App state (paginatedListsOverride, onTogglePaginatedLists, …) and all tests/fixtures + mode-referring comments. "single-page" wording that denotes the SDK's one-page `client.request` verb (inspectorClient) is intentionally left as-is. Purely a rename — no behavior change; unit + integration + cli + tui + coverage all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Format App.test.tsx (prettier) after the paginatedLists rename (#1721) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d552564966
commit
b3b04c6939
15 changed files with 2494 additions and 247 deletions
|
|
@ -251,6 +251,39 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("round-trips paginatedLists: lifts true to settings and back to disk", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
delta: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
paginatedLists: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(original);
|
||||
expect(entry?.settings?.paginatedLists).toBe(true);
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("omits paginatedLists from disk when false (the default)", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
epsilon: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
connectionTimeout: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(original);
|
||||
expect(entry?.settings?.paginatedLists).toBe(false);
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect("paginatedLists" in (round.mcpServers.epsilon ?? {})).toBe(false);
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("round-trips maxFetchRequests: lifts a non-default value to settings and back to disk", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
|
|
@ -397,6 +430,8 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
taskTtl: 60000,
|
||||
// Absent autoRefreshOnListChanged on disk → false in memory (for the form)
|
||||
autoRefreshOnListChanged: false,
|
||||
// Absent paginatedLists on disk → false in memory (for the form)
|
||||
paginatedLists: false,
|
||||
// Absent maxFetchRequests on disk → product default in memory (for the form)
|
||||
maxFetchRequests: 1000,
|
||||
// Absent roots on disk → empty list in memory (for the form)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ describe("ManagedPromptsState", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
// Default to a server that advertises `prompts` so the existing flow tests
|
||||
// exercise the live `listPrompts` path; capability-absent tests below
|
||||
// exercise the live `listAllPrompts` path; capability-absent tests below
|
||||
// override this.
|
||||
client = new FakeInspectorClient({ capabilities: { prompts: {} } });
|
||||
state = new ManagedPromptsState(client, 0);
|
||||
|
|
@ -51,13 +51,13 @@ describe("ManagedPromptsState", () => {
|
|||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listPrompts when disconnected", async () => {
|
||||
it("refresh returns early and does not call listAllPrompts when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listPrompts).not.toHaveBeenCalled();
|
||||
expect(client.listAllPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh skips listPrompts when the server doesn't advertise prompts capability", async () => {
|
||||
it("refresh skips listAllPrompts when the server doesn't advertise prompts capability", async () => {
|
||||
// Regression (#1350): a prompts-less server replied to prompts/list with
|
||||
// -32601 "Method not found", surfacing in the console on every connect.
|
||||
const promptless = new FakeInspectorClient({
|
||||
|
|
@ -68,10 +68,10 @@ describe("ManagedPromptsState", () => {
|
|||
|
||||
const result = await promptlessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(promptless.listPrompts).not.toHaveBeenCalled();
|
||||
expect(promptless.listAllPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connect against a prompts-less server doesn't fire listPrompts", async () => {
|
||||
it("connect against a prompts-less server doesn't fire listAllPrompts", async () => {
|
||||
// The connect event runs refresh; the capability gate must also catch it
|
||||
// there, not only the publicly-callable refresh().
|
||||
const promptless = new FakeInspectorClient({ capabilities: { tools: {} } });
|
||||
|
|
@ -81,11 +81,11 @@ describe("ManagedPromptsState", () => {
|
|||
const changePromise = waitForPromptsChange(promptlessState);
|
||||
promptless.dispatchTypedEvent("connect");
|
||||
await changePromise;
|
||||
expect(promptless.listPrompts).not.toHaveBeenCalled();
|
||||
expect(promptless.listAllPrompts).not.toHaveBeenCalled();
|
||||
expect(promptlessState.getPrompts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches promptsChange", async () => {
|
||||
it("refresh fetches the full list and dispatches promptsChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
|
||||
|
||||
|
|
@ -97,7 +97,9 @@ describe("ManagedPromptsState", () => {
|
|||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
it("refresh delegates all-page aggregation to listAllPrompts (one call)", async () => {
|
||||
// The SDK's high-level verb walks every page; the managed state makes a
|
||||
// single `listAllPrompts` call rather than looping single pages itself.
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages(
|
||||
{ prompts: [prompt("a")], nextCursor: "c1" },
|
||||
|
|
@ -107,15 +109,18 @@ describe("ManagedPromptsState", () => {
|
|||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((p) => p.name)).toEqual(["a", "b", "c"]);
|
||||
expect(client.listPrompts).toHaveBeenCalledTimes(3);
|
||||
expect(client.listAllPrompts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refresh passes setMetadata-supplied metadata to listPrompts", async () => {
|
||||
it("refresh passes setMetadata-supplied metadata to listAllPrompts", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "v" });
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh();
|
||||
expect(client.listPrompts).toHaveBeenCalledWith(undefined, { k: "v" });
|
||||
expect(client.listAllPrompts).toHaveBeenCalledWith({
|
||||
cacheMode: undefined,
|
||||
metadata: { k: "v" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh argument overrides setMetadata", async () => {
|
||||
|
|
@ -123,8 +128,9 @@ describe("ManagedPromptsState", () => {
|
|||
state.setMetadata({ k: "default" });
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh({ k: "override" });
|
||||
expect(client.listPrompts).toHaveBeenCalledWith(undefined, {
|
||||
k: "override",
|
||||
expect(client.listAllPrompts).toHaveBeenCalledWith({
|
||||
cacheMode: undefined,
|
||||
metadata: { k: "override" },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -144,11 +150,11 @@ describe("ManagedPromptsState", () => {
|
|||
const changed = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("promptsListChanged");
|
||||
expect(await changed).toBe(true);
|
||||
expect(client.listPrompts).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(client.listAllPrompts).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(state.getPrompts()).toEqual([]); // displayed list untouched
|
||||
});
|
||||
|
||||
it("promptsListChanged auto-refreshes when the server opts in", async () => {
|
||||
it("promptsListChanged auto-refreshes (cacheMode:refresh) when the server opts in", async () => {
|
||||
const autoClient = new FakeInspectorClient({
|
||||
capabilities: { prompts: {} },
|
||||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
|
|
@ -159,7 +165,11 @@ describe("ManagedPromptsState", () => {
|
|||
const changed = waitForPromptsChange(autoState);
|
||||
autoClient.dispatchTypedEvent("promptsListChanged");
|
||||
expect((await changed).map((p) => p.name)).toEqual(["a"]);
|
||||
expect(autoClient.listPrompts).toHaveBeenCalled();
|
||||
// A list_changed means the prior list is stale → bypass the cache.
|
||||
expect(autoClient.listAllPrompts).toHaveBeenCalledWith({
|
||||
cacheMode: "refresh",
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears prompts and dispatches promptsChange", async () => {
|
||||
|
|
@ -196,13 +206,16 @@ describe("ManagedPromptsState", () => {
|
|||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
it("refresh forwards an explicit cacheMode to listAllPrompts", async () => {
|
||||
// A user-initiated refresh (via the hook) passes cacheMode:"refresh" to
|
||||
// force a cache-bypassing round trip on modern servers (#1721).
|
||||
client.setStatus("connected");
|
||||
client.listPrompts.mockImplementation(async () => ({
|
||||
prompts: [prompt("a")],
|
||||
nextCursor: "always",
|
||||
}));
|
||||
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.refresh(undefined, "refresh");
|
||||
expect(client.listAllPrompts).toHaveBeenCalledWith({
|
||||
cacheMode: "refresh",
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
// Default to a server that advertises `resources` so the existing flow
|
||||
// tests exercise the live `listResourceTemplates` path; capability-absent
|
||||
// tests exercise the live `listAllResourceTemplates` path; capability-absent
|
||||
// tests below override this. (Templates are gated on the `resources`
|
||||
// capability — the spec defines no separate `resourceTemplates` one.)
|
||||
client = new FakeInspectorClient({ capabilities: { resources: {} } });
|
||||
|
|
@ -50,13 +50,13 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listResourceTemplates when disconnected", async () => {
|
||||
it("refresh returns early and does not call listAllResourceTemplates when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listResourceTemplates).not.toHaveBeenCalled();
|
||||
expect(client.listAllResourceTemplates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh skips listResourceTemplates when the server doesn't advertise resources capability", async () => {
|
||||
it("refresh skips listAllResourceTemplates when the server doesn't advertise resources capability", async () => {
|
||||
// Regression (#1350): templates are part of the resources surface, so a
|
||||
// resources-less server replied to resources/templates/list with -32601
|
||||
// "Method not found", surfacing in the console on every connect.
|
||||
|
|
@ -71,10 +71,10 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
|
||||
const result = await resourcelessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(resourceless.listResourceTemplates).not.toHaveBeenCalled();
|
||||
expect(resourceless.listAllResourceTemplates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connect against a resources-less server doesn't fire listResourceTemplates", async () => {
|
||||
it("connect against a resources-less server doesn't fire listAllResourceTemplates", async () => {
|
||||
// The connect event runs refresh; the capability gate must also catch it
|
||||
// there, not only the publicly-callable refresh().
|
||||
const resourceless = new FakeInspectorClient({
|
||||
|
|
@ -89,11 +89,11 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
const changePromise = waitForChange(resourcelessState);
|
||||
resourceless.dispatchTypedEvent("connect");
|
||||
await changePromise;
|
||||
expect(resourceless.listResourceTemplates).not.toHaveBeenCalled();
|
||||
expect(resourceless.listAllResourceTemplates).not.toHaveBeenCalled();
|
||||
expect(resourcelessState.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches resourceTemplatesChange", async () => {
|
||||
it("refresh fetches the full list and dispatches resourceTemplatesChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({
|
||||
resourceTemplates: [template("a"), template("b")],
|
||||
|
|
@ -107,7 +107,9 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
it("refresh delegates all-page aggregation to listAllResourceTemplates (one call)", async () => {
|
||||
// The SDK's high-level verb walks every page; the managed state makes a
|
||||
// single `listAllResourceTemplates` call rather than looping single pages itself.
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages(
|
||||
{ resourceTemplates: [template("a")], nextCursor: "c1" },
|
||||
|
|
@ -117,7 +119,7 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((t) => t.name)).toEqual(["a", "b", "c"]);
|
||||
expect(client.listResourceTemplates).toHaveBeenCalledTimes(3);
|
||||
expect(client.listAllResourceTemplates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refresh passes setMetadata-supplied metadata", async () => {
|
||||
|
|
@ -125,8 +127,9 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
state.setMetadata({ k: "v" });
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh();
|
||||
expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, {
|
||||
k: "v",
|
||||
expect(client.listAllResourceTemplates).toHaveBeenCalledWith({
|
||||
cacheMode: undefined,
|
||||
metadata: { k: "v" },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -135,8 +138,9 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
state.setMetadata({ k: "default" });
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh({ k: "override" });
|
||||
expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, {
|
||||
k: "override",
|
||||
expect(client.listAllResourceTemplates).toHaveBeenCalledWith({
|
||||
cacheMode: undefined,
|
||||
metadata: { k: "override" },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -149,6 +153,25 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
expect(next.map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("STILL loads on connect in paginated mode (no paged counterpart, #1721)", async () => {
|
||||
// Unlike tools/prompts/resources, resource templates have no paged
|
||||
// counterpart, so they must aggregate on connect even when
|
||||
// `paginatedLists` is on — otherwise the list would be empty until a
|
||||
// manual refresh (deferWhenPaginated: false).
|
||||
const spClient = new FakeInspectorClient({
|
||||
capabilities: { resources: {} },
|
||||
serverSettings: { ...AUTO_REFRESH_SETTINGS, paginatedLists: true },
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new ManagedResourceTemplatesState(spClient, 0);
|
||||
spClient.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
const changePromise = waitForChange(spState);
|
||||
spClient.dispatchTypedEvent("connect");
|
||||
expect((await changePromise).map((t) => t.name)).toEqual(["a"]);
|
||||
expect(spClient.listAllResourceTemplates).toHaveBeenCalled();
|
||||
spState.destroy();
|
||||
});
|
||||
|
||||
it("resourceTemplatesListChanged does NOT auto-refresh by default (refreshed via the Resources Refresh)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourceTemplatePages({
|
||||
|
|
@ -158,11 +181,11 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
// Yield so a stray refresh would have landed.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(client.listResourceTemplates).not.toHaveBeenCalled();
|
||||
expect(client.listAllResourceTemplates).not.toHaveBeenCalled();
|
||||
expect(state.getResourceTemplates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("resourceTemplatesListChanged auto-refreshes when the server opts in", async () => {
|
||||
it("resourceTemplatesListChanged auto-refreshes (cacheMode:refresh) when the server opts in", async () => {
|
||||
const autoClient = new FakeInspectorClient({
|
||||
capabilities: { resources: {} },
|
||||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
|
|
@ -175,7 +198,11 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
const changed = waitForChange(autoState);
|
||||
autoClient.dispatchTypedEvent("resourceTemplatesListChanged");
|
||||
expect((await changed).map((t) => t.name)).toEqual(["a"]);
|
||||
expect(autoClient.listResourceTemplates).toHaveBeenCalled();
|
||||
// A list_changed means the prior list is stale → bypass the cache.
|
||||
expect(autoClient.listAllResourceTemplates).toHaveBeenCalledWith({
|
||||
cacheMode: "refresh",
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears templates and dispatches change", async () => {
|
||||
|
|
@ -212,13 +239,16 @@ describe("ManagedResourceTemplatesState", () => {
|
|||
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
it("refresh forwards an explicit cacheMode to listAllResourceTemplates", async () => {
|
||||
// A user-initiated refresh (via the hook) passes cacheMode:"refresh" to
|
||||
// force a cache-bypassing round trip on modern servers (#1721).
|
||||
client.setStatus("connected");
|
||||
client.listResourceTemplates.mockImplementation(async () => ({
|
||||
resourceTemplates: [template("a")],
|
||||
nextCursor: "always",
|
||||
}));
|
||||
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
|
||||
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
|
||||
await state.refresh(undefined, "refresh");
|
||||
expect(client.listAllResourceTemplates).toHaveBeenCalledWith({
|
||||
cacheMode: "refresh",
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ describe("ManagedResourcesState", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
// Default to a server that advertises `resources` so the existing flow
|
||||
// tests exercise the live `listResources` path; capability-absent tests
|
||||
// tests exercise the live `listAllResources` path; capability-absent tests
|
||||
// below override this.
|
||||
client = new FakeInspectorClient({ capabilities: { resources: {} } });
|
||||
state = new ManagedResourcesState(client, 0);
|
||||
|
|
@ -53,13 +53,13 @@ describe("ManagedResourcesState", () => {
|
|||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listResources when disconnected", async () => {
|
||||
it("refresh returns early and does not call listAllResources when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listResources).not.toHaveBeenCalled();
|
||||
expect(client.listAllResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh skips listResources when the server doesn't advertise resources capability", async () => {
|
||||
it("refresh skips listAllResources when the server doesn't advertise resources capability", async () => {
|
||||
// Regression (#1350): a resources-less server replied to resources/list
|
||||
// with -32601 "Method not found", surfacing in the console on every
|
||||
// connect.
|
||||
|
|
@ -71,10 +71,10 @@ describe("ManagedResourcesState", () => {
|
|||
|
||||
const result = await resourcelessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(resourceless.listResources).not.toHaveBeenCalled();
|
||||
expect(resourceless.listAllResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connect against a resources-less server doesn't fire listResources", async () => {
|
||||
it("connect against a resources-less server doesn't fire listAllResources", async () => {
|
||||
// The connect event runs refresh; the capability gate must also catch it
|
||||
// there, not only the publicly-callable refresh().
|
||||
const resourceless = new FakeInspectorClient({
|
||||
|
|
@ -86,11 +86,11 @@ describe("ManagedResourcesState", () => {
|
|||
const changePromise = waitForResourcesChange(resourcelessState);
|
||||
resourceless.dispatchTypedEvent("connect");
|
||||
await changePromise;
|
||||
expect(resourceless.listResources).not.toHaveBeenCalled();
|
||||
expect(resourceless.listAllResources).not.toHaveBeenCalled();
|
||||
expect(resourcelessState.getResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches resourcesChange", async () => {
|
||||
it("refresh fetches the full list and dispatches resourcesChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({
|
||||
resources: [resource("a://1"), resource("a://2")],
|
||||
|
|
@ -104,7 +104,9 @@ describe("ManagedResourcesState", () => {
|
|||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1", "a://2"]);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
it("refresh delegates all-page aggregation to listAllResources (one call)", async () => {
|
||||
// The SDK's high-level verb walks every page; the managed state makes a
|
||||
// single `listAllResources` call rather than looping single pages itself.
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages(
|
||||
{ resources: [resource("a://1")], nextCursor: "c1" },
|
||||
|
|
@ -114,15 +116,18 @@ describe("ManagedResourcesState", () => {
|
|||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((r) => r.uri)).toEqual(["a://1", "a://2", "a://3"]);
|
||||
expect(client.listResources).toHaveBeenCalledTimes(3);
|
||||
expect(client.listAllResources).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refresh passes setMetadata-supplied metadata to listResources", async () => {
|
||||
it("refresh passes setMetadata-supplied metadata to listAllResources", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "v" });
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh();
|
||||
expect(client.listResources).toHaveBeenCalledWith(undefined, { k: "v" });
|
||||
expect(client.listAllResources).toHaveBeenCalledWith({
|
||||
cacheMode: undefined,
|
||||
metadata: { k: "v" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh argument overrides setMetadata", async () => {
|
||||
|
|
@ -130,8 +135,9 @@ describe("ManagedResourcesState", () => {
|
|||
state.setMetadata({ k: "default" });
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh({ k: "override" });
|
||||
expect(client.listResources).toHaveBeenCalledWith(undefined, {
|
||||
k: "override",
|
||||
expect(client.listAllResources).toHaveBeenCalledWith({
|
||||
cacheMode: undefined,
|
||||
metadata: { k: "override" },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -151,11 +157,11 @@ describe("ManagedResourcesState", () => {
|
|||
const changed = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("resourcesListChanged");
|
||||
expect(await changed).toBe(true);
|
||||
expect(client.listResources).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(client.listAllResources).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(state.getResources()).toEqual([]); // displayed list untouched
|
||||
});
|
||||
|
||||
it("resourcesListChanged auto-refreshes when the server opts in", async () => {
|
||||
it("resourcesListChanged auto-refreshes (cacheMode:refresh) when the server opts in", async () => {
|
||||
const autoClient = new FakeInspectorClient({
|
||||
capabilities: { resources: {} },
|
||||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
|
|
@ -166,7 +172,11 @@ describe("ManagedResourcesState", () => {
|
|||
const changed = waitForResourcesChange(autoState);
|
||||
autoClient.dispatchTypedEvent("resourcesListChanged");
|
||||
expect((await changed).map((r) => r.uri)).toEqual(["a://1"]);
|
||||
expect(autoClient.listResources).toHaveBeenCalled();
|
||||
// A list_changed means the prior list is stale → bypass the cache.
|
||||
expect(autoClient.listAllResources).toHaveBeenCalledWith({
|
||||
cacheMode: "refresh",
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("statusChange to disconnected clears resources and dispatches resourcesChange", async () => {
|
||||
|
|
@ -203,13 +213,16 @@ describe("ManagedResourcesState", () => {
|
|||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
it("refresh forwards an explicit cacheMode to listAllResources", async () => {
|
||||
// A user-initiated refresh (via the hook) passes cacheMode:"refresh" to
|
||||
// force a cache-bypassing round trip on modern servers (#1721).
|
||||
client.setStatus("connected");
|
||||
client.listResources.mockImplementation(async () => ({
|
||||
resources: [resource("a://1")],
|
||||
nextCursor: "always",
|
||||
}));
|
||||
await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/);
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.refresh(undefined, "refresh");
|
||||
expect(client.listAllResources).toHaveBeenCalledWith({
|
||||
cacheMode: "refresh",
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
|||
roots: [],
|
||||
};
|
||||
|
||||
// Paginated mode on, plus auto-refresh on — paginated must win so the
|
||||
// aggregate walk never runs (#1721).
|
||||
const PAGINATED_SETTINGS: InspectorServerSettings = {
|
||||
...AUTO_REFRESH_SETTINGS,
|
||||
paginatedLists: true,
|
||||
};
|
||||
|
||||
function waitForToolsChange(state: ManagedToolsState): Promise<Tool[]> {
|
||||
return waitForChangeEvent(state, "toolsChange");
|
||||
}
|
||||
|
|
@ -35,7 +42,7 @@ describe("ManagedToolsState", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
// Default to a server that advertises `tools` so the existing flow tests
|
||||
// exercise the live `listTools` path; capability-absent tests below
|
||||
// exercise the live `listAllTools` path; capability-absent tests below
|
||||
// override this.
|
||||
client = new FakeInspectorClient({ capabilities: { tools: {} } });
|
||||
state = new ManagedToolsState(client, 0);
|
||||
|
|
@ -51,13 +58,13 @@ describe("ManagedToolsState", () => {
|
|||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("refresh returns early and does not call listTools when disconnected", async () => {
|
||||
it("refresh returns early and does not call listAllTools when disconnected", async () => {
|
||||
const result = await state.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(client.listTools).not.toHaveBeenCalled();
|
||||
expect(client.listAllTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh skips listTools when the server doesn't advertise tools capability", async () => {
|
||||
it("refresh skips listAllTools when the server doesn't advertise tools capability", async () => {
|
||||
// Regression (#1350): a tools-less server replied to tools/list with
|
||||
// -32601 "Method not found", surfacing in the console on every connect.
|
||||
const toolless = new FakeInspectorClient({
|
||||
|
|
@ -68,10 +75,10 @@ describe("ManagedToolsState", () => {
|
|||
|
||||
const result = await toollessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(toolless.listTools).not.toHaveBeenCalled();
|
||||
expect(toolless.listAllTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connect against a tools-less server doesn't fire listTools", async () => {
|
||||
it("connect against a tools-less server doesn't fire listAllTools", async () => {
|
||||
// The connect event runs refresh; the capability gate must also catch it
|
||||
// there, not only the publicly-callable refresh().
|
||||
const toolless = new FakeInspectorClient({ capabilities: { prompts: {} } });
|
||||
|
|
@ -81,11 +88,11 @@ describe("ManagedToolsState", () => {
|
|||
const changePromise = waitForToolsChange(toollessState);
|
||||
toolless.dispatchTypedEvent("connect");
|
||||
await changePromise;
|
||||
expect(toolless.listTools).not.toHaveBeenCalled();
|
||||
expect(toolless.listAllTools).not.toHaveBeenCalled();
|
||||
expect(toollessState.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches toolsChange", async () => {
|
||||
it("refresh fetches the full list and dispatches toolsChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a"), tool("b")] });
|
||||
|
||||
|
|
@ -97,7 +104,9 @@ describe("ManagedToolsState", () => {
|
|||
expect(state.getTools().map((t) => t.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("refresh accumulates across multiple paginated pages", async () => {
|
||||
it("refresh delegates all-page aggregation to listAllTools (one call)", async () => {
|
||||
// The SDK's high-level verb walks every page; the managed state makes a
|
||||
// single `listAllTools` call rather than looping single pages itself.
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages(
|
||||
{ tools: [tool("a")], nextCursor: "c1" },
|
||||
|
|
@ -107,15 +116,18 @@ describe("ManagedToolsState", () => {
|
|||
|
||||
const result = await state.refresh();
|
||||
expect(result.map((t) => t.name)).toEqual(["a", "b", "c"]);
|
||||
expect(client.listTools).toHaveBeenCalledTimes(3);
|
||||
expect(client.listAllTools).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refresh passes setMetadata-supplied metadata to listTools", async () => {
|
||||
it("refresh passes setMetadata-supplied metadata to listAllTools", async () => {
|
||||
client.setStatus("connected");
|
||||
state.setMetadata({ k: "v" });
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh();
|
||||
expect(client.listTools).toHaveBeenCalledWith(undefined, { k: "v" });
|
||||
expect(client.listAllTools).toHaveBeenCalledWith({
|
||||
cacheMode: undefined,
|
||||
metadata: { k: "v" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh argument overrides setMetadata", async () => {
|
||||
|
|
@ -123,7 +135,55 @@ describe("ManagedToolsState", () => {
|
|||
state.setMetadata({ k: "default" });
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh({ k: "override" });
|
||||
expect(client.listTools).toHaveBeenCalledWith(undefined, { k: "override" });
|
||||
expect(client.listAllTools).toHaveBeenCalledWith({
|
||||
cacheMode: undefined,
|
||||
metadata: { k: "override" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh forwards an explicit cacheMode to listAllTools", async () => {
|
||||
// A user-initiated refresh (via the hook) passes cacheMode:"refresh" to
|
||||
// force a cache-bypassing round trip on modern servers (#1721).
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.refresh(undefined, "refresh");
|
||||
expect(client.listAllTools).toHaveBeenCalledWith({
|
||||
cacheMode: "refresh",
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("connect does NOT fetch in paginated mode (#1721)", async () => {
|
||||
// The aggregate list isn't the display source in paginated mode, so the
|
||||
// connect-time all-page walk is skipped (the defensive point of the setting).
|
||||
const spClient = new FakeInspectorClient({
|
||||
capabilities: { tools: {} },
|
||||
serverSettings: PAGINATED_SETTINGS,
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new ManagedToolsState(spClient, 0);
|
||||
spClient.queueToolPages({ tools: [tool("a")] });
|
||||
spClient.dispatchTypedEvent("connect");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(spClient.listAllTools).not.toHaveBeenCalled();
|
||||
expect(spState.getTools()).toEqual([]);
|
||||
spState.destroy();
|
||||
});
|
||||
|
||||
it("list_changed only lights the indicator in paginated mode, never aggregates (#1721)", async () => {
|
||||
// Paginated wins over autoRefreshOnListChanged: the indicator lights but
|
||||
// the aggregate walk never runs.
|
||||
const spClient = new FakeInspectorClient({
|
||||
capabilities: { tools: {} },
|
||||
serverSettings: PAGINATED_SETTINGS,
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new ManagedToolsState(spClient, 0);
|
||||
const changed = waitForListChanged(spState);
|
||||
spClient.dispatchTypedEvent("toolsListChanged");
|
||||
expect(await changed).toBe(true);
|
||||
expect(spClient.listAllTools).not.toHaveBeenCalled();
|
||||
spState.destroy();
|
||||
});
|
||||
|
||||
it("connect event triggers a refresh", async () => {
|
||||
|
|
@ -142,7 +202,7 @@ describe("ManagedToolsState", () => {
|
|||
const changed = waitForListChanged(state);
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
expect(await changed).toBe(true);
|
||||
expect(client.listTools).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(client.listAllTools).not.toHaveBeenCalled(); // no automatic fetch
|
||||
expect(state.getTools()).toEqual([]); // displayed list untouched
|
||||
});
|
||||
|
||||
|
|
@ -160,7 +220,7 @@ describe("ManagedToolsState", () => {
|
|||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fired).toBe(1); // one debounced flip
|
||||
expect(state.getListChanged()).toBe(true);
|
||||
expect(client.listTools).not.toHaveBeenCalled();
|
||||
expect(client.listAllTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces an auto-refresh that fires while an earlier one is still fetching (#1444)", async () => {
|
||||
|
|
@ -173,7 +233,7 @@ describe("ManagedToolsState", () => {
|
|||
autoClient.setStatus("connected");
|
||||
const autoState = new ManagedToolsState(autoClient, 0);
|
||||
let release: (value: { tools: Tool[] }) => void = () => {};
|
||||
autoClient.listTools.mockImplementationOnce(
|
||||
autoClient.listAllTools.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ tools: Tool[] }>((resolve) => {
|
||||
release = resolve;
|
||||
|
|
@ -183,20 +243,20 @@ describe("ManagedToolsState", () => {
|
|||
|
||||
autoClient.dispatchTypedEvent("toolsListChanged");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(autoClient.listTools).toHaveBeenCalledTimes(1);
|
||||
expect(autoClient.listAllTools).toHaveBeenCalledTimes(1);
|
||||
|
||||
autoClient.dispatchTypedEvent("toolsListChanged");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(autoClient.listTools).toHaveBeenCalledTimes(1); // coalesced
|
||||
expect(autoClient.listAllTools).toHaveBeenCalledTimes(1); // coalesced
|
||||
|
||||
release({ tools: [tool("a")] });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(autoClient.listTools).toHaveBeenCalledTimes(2);
|
||||
expect(autoClient.listAllTools).toHaveBeenCalledTimes(2);
|
||||
// The (coalesced) auto-refresh applied the new list.
|
||||
expect(autoState.getTools().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("toolsListChanged auto-refreshes when the server opts in", async () => {
|
||||
it("toolsListChanged auto-refreshes (cacheMode:refresh) when the server opts in", async () => {
|
||||
const autoClient = new FakeInspectorClient({
|
||||
capabilities: { tools: {} },
|
||||
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||
|
|
@ -207,7 +267,11 @@ describe("ManagedToolsState", () => {
|
|||
const changed = waitForToolsChange(autoState);
|
||||
autoClient.dispatchTypedEvent("toolsListChanged");
|
||||
expect((await changed).map((t) => t.name)).toEqual(["a"]);
|
||||
expect(autoClient.listTools).toHaveBeenCalled();
|
||||
// A list_changed means the prior list is stale → bypass the cache.
|
||||
expect(autoClient.listAllTools).toHaveBeenCalledWith({
|
||||
cacheMode: "refresh",
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors a live setServerSettings toggle without a reconnect (#1444)", async () => {
|
||||
|
|
@ -256,14 +320,27 @@ describe("ManagedToolsState", () => {
|
|||
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("throws when pagination exceeds 100 pages", async () => {
|
||||
it("clears a pending list_changed debounce timer when the connection goes terminal", async () => {
|
||||
// A non-zero debounce leaves the timer pending; a terminal statusChange must
|
||||
// clear it so no stale indicator/fetch fires after disconnect.
|
||||
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/);
|
||||
const debounced = new ManagedToolsState(client, 1000);
|
||||
client.dispatchTypedEvent("toolsListChanged"); // schedules the debounce timer
|
||||
client.setStatus("disconnected"); // terminal → clears the pending timer
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(debounced.getListChanged()).toBe(false);
|
||||
debounced.destroy();
|
||||
});
|
||||
|
||||
it("clears a pending list_changed debounce timer on destroy", async () => {
|
||||
client.setStatus("connected");
|
||||
const debounced = new ManagedToolsState(client, 1000);
|
||||
client.dispatchTypedEvent("toolsListChanged"); // schedules the debounce timer
|
||||
debounced.destroy(); // unsubscribe clears the pending timer
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
// No indicator lit and the list stays empty — the timer never fired.
|
||||
expect(debounced.getListChanged()).toBe(false);
|
||||
expect(debounced.getTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("destroy unsubscribes from client events and clears state", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
|
|
@ -7,6 +8,18 @@ function prompt(name: string): Prompt {
|
|||
return { name };
|
||||
}
|
||||
|
||||
const PAGINATED_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
paginatedLists: true,
|
||||
};
|
||||
|
||||
function waitForChange(state: PagedPromptsState): Promise<Prompt[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("promptsChange", (e) => resolve(e.detail), {
|
||||
|
|
@ -111,4 +124,81 @@ describe("PagedPromptsState", () => {
|
|||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it("ignores a concurrent loadPage (double-click guard, #1721)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages(
|
||||
{ prompts: [prompt("a")], nextCursor: "c1" },
|
||||
{ prompts: [prompt("b")], nextCursor: "c2" },
|
||||
);
|
||||
await state.loadPage();
|
||||
const [r1, r2] = await Promise.all([
|
||||
state.loadPage("c1"),
|
||||
state.loadPage("c1"),
|
||||
]);
|
||||
expect(r1.prompts.map((p) => p.name)).toEqual(["b"]);
|
||||
expect(r2.prompts).toEqual([]);
|
||||
expect(r2.nextCursor).toBe("c1");
|
||||
expect(state.getPrompts().map((p) => p.name)).toEqual(["a", "b"]);
|
||||
expect(state.getPagination().pageCount).toBe(2);
|
||||
});
|
||||
|
||||
describe("pagination progress + connect auto-load (#1721)", () => {
|
||||
it("tracks nextCursor/page count and dispatches paginationChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages(
|
||||
{ prompts: [prompt("a")], nextCursor: "c1" },
|
||||
{ prompts: [prompt("b")] },
|
||||
);
|
||||
const onLoad = new Promise<{ nextCursor?: string; pageCount: number }>(
|
||||
(resolve) => {
|
||||
state.addEventListener("paginationChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
await state.loadPage();
|
||||
expect(await onLoad).toEqual({ nextCursor: "c1", pageCount: 1 });
|
||||
await state.loadPage("c1");
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets pagination on disconnect and clear", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queuePromptPages({ prompts: [prompt("a")], nextCursor: "c1" });
|
||||
await state.loadPage();
|
||||
state.clear();
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
});
|
||||
await state.loadPage();
|
||||
client.setStatus("disconnected");
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads page 1 on connect in paginated mode, not otherwise", async () => {
|
||||
const spClient = new FakeInspectorClient({
|
||||
serverSettings: PAGINATED_SETTINGS,
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new PagedPromptsState(spClient);
|
||||
spClient.queuePromptPages({ prompts: [prompt("a")] });
|
||||
const changed = waitForChange(spState);
|
||||
spClient.dispatchTypedEvent("connect");
|
||||
expect((await changed).map((p) => p.name)).toEqual(["a"]);
|
||||
spState.destroy();
|
||||
|
||||
client.setStatus("connected");
|
||||
client.dispatchTypedEvent("connect");
|
||||
await Promise.resolve();
|
||||
expect(client.listPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
|
|
@ -7,6 +8,18 @@ function resource(uri: string): Resource {
|
|||
return { uri, name: uri };
|
||||
}
|
||||
|
||||
const PAGINATED_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
paginatedLists: true,
|
||||
};
|
||||
|
||||
function waitForChange(state: PagedResourcesState): Promise<Resource[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("resourcesChange", (e) => resolve(e.detail), {
|
||||
|
|
@ -113,4 +126,84 @@ describe("PagedResourcesState", () => {
|
|||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it("ignores a concurrent loadPage (double-click guard, #1721)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages(
|
||||
{ resources: [resource("a://1")], nextCursor: "c1" },
|
||||
{ resources: [resource("a://2")], nextCursor: "c2" },
|
||||
);
|
||||
await state.loadPage();
|
||||
const [r1, r2] = await Promise.all([
|
||||
state.loadPage("c1"),
|
||||
state.loadPage("c1"),
|
||||
]);
|
||||
expect(r1.resources.map((r) => r.uri)).toEqual(["a://2"]);
|
||||
expect(r2.resources).toEqual([]);
|
||||
expect(r2.nextCursor).toBe("c1");
|
||||
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1", "a://2"]);
|
||||
expect(state.getPagination().pageCount).toBe(2);
|
||||
});
|
||||
|
||||
describe("pagination progress + connect auto-load (#1721)", () => {
|
||||
it("tracks nextCursor/page count and dispatches paginationChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages(
|
||||
{ resources: [resource("a://1")], nextCursor: "c1" },
|
||||
{ resources: [resource("a://2")] },
|
||||
);
|
||||
const onLoad = new Promise<{ nextCursor?: string; pageCount: number }>(
|
||||
(resolve) => {
|
||||
state.addEventListener("paginationChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
await state.loadPage();
|
||||
expect(await onLoad).toEqual({ nextCursor: "c1", pageCount: 1 });
|
||||
await state.loadPage("c1");
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets pagination on disconnect and clear", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueResourcePages({
|
||||
resources: [resource("a://1")],
|
||||
nextCursor: "c1",
|
||||
});
|
||||
await state.loadPage();
|
||||
state.clear();
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
});
|
||||
await state.loadPage();
|
||||
client.setStatus("disconnected");
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads page 1 on connect in paginated mode, not otherwise", async () => {
|
||||
const spClient = new FakeInspectorClient({
|
||||
serverSettings: PAGINATED_SETTINGS,
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new PagedResourcesState(spClient);
|
||||
spClient.queueResourcePages({ resources: [resource("a://1")] });
|
||||
const changed = waitForChange(spState);
|
||||
spClient.dispatchTypedEvent("connect");
|
||||
expect((await changed).map((r) => r.uri)).toEqual(["a://1"]);
|
||||
spState.destroy();
|
||||
|
||||
client.setStatus("connected");
|
||||
client.dispatchTypedEvent("connect");
|
||||
await Promise.resolve();
|
||||
expect(client.listResources).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
import { PagedToolsState } from "@inspector/core/mcp/state/pagedToolsState";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
|
||||
|
|
@ -7,6 +8,18 @@ function tool(name: string): Tool {
|
|||
return { name, inputSchema: { type: "object" } };
|
||||
}
|
||||
|
||||
const PAGINATED_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
paginatedLists: true,
|
||||
};
|
||||
|
||||
function waitForChange(state: PagedToolsState): Promise<Tool[]> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("toolsChange", (e) => resolve(e.detail), {
|
||||
|
|
@ -120,4 +133,109 @@ describe("PagedToolsState", () => {
|
|||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
describe("pagination progress (#1721)", () => {
|
||||
it("tracks nextCursor and page count across loads", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages(
|
||||
{ tools: [tool("a")], nextCursor: "c1" },
|
||||
{ tools: [tool("b")] },
|
||||
);
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
});
|
||||
await state.loadPage();
|
||||
expect(state.getPagination()).toEqual({ nextCursor: "c1", pageCount: 1 });
|
||||
await state.loadPage("c1");
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches paginationChange on load and reset", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")], nextCursor: "c1" });
|
||||
const onLoad = new Promise<{ nextCursor?: string; pageCount: number }>(
|
||||
(resolve) => {
|
||||
state.addEventListener("paginationChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
await state.loadPage();
|
||||
expect(await onLoad).toEqual({ nextCursor: "c1", pageCount: 1 });
|
||||
|
||||
const onClear = new Promise<{ nextCursor?: string; pageCount: number }>(
|
||||
(resolve) => {
|
||||
state.addEventListener("paginationChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
state.clear();
|
||||
expect(await onClear).toEqual({ nextCursor: undefined, pageCount: 0 });
|
||||
});
|
||||
|
||||
it("resets pagination when the connection goes terminal", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")], nextCursor: "c1" });
|
||||
await state.loadPage();
|
||||
client.setStatus("disconnected");
|
||||
expect(state.getPagination()).toEqual({
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a concurrent loadPage (double-click guard, #1721)", async () => {
|
||||
// Two overlapping loadPage(cursor) calls must not both append the same
|
||||
// page. The second (while the first is in flight) is a no-op that returns
|
||||
// the current cursor.
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages(
|
||||
{ tools: [tool("a")], nextCursor: "c1" },
|
||||
{ tools: [tool("b")], nextCursor: "c2" },
|
||||
);
|
||||
await state.loadPage(); // page 1 → cursor c1
|
||||
const first = state.loadPage("c1");
|
||||
const second = state.loadPage("c1"); // concurrent — should be dropped
|
||||
const [r1, r2] = await Promise.all([first, second]);
|
||||
expect(r1.tools.map((t) => t.name)).toEqual(["b"]);
|
||||
expect(r2.tools).toEqual([]); // guarded no-op
|
||||
expect(r2.nextCursor).toBe("c1"); // preserves the in-flight cursor
|
||||
// Page appended exactly once.
|
||||
expect(state.getTools().map((t) => t.name)).toEqual(["a", "b"]);
|
||||
expect(state.getPagination().pageCount).toBe(2);
|
||||
});
|
||||
|
||||
describe("connect auto-load (#1721)", () => {
|
||||
it("loads page 1 on connect in paginated mode", async () => {
|
||||
const spClient = new FakeInspectorClient({
|
||||
serverSettings: PAGINATED_SETTINGS,
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new PagedToolsState(spClient);
|
||||
spClient.queueToolPages({ tools: [tool("a")], nextCursor: "c1" });
|
||||
const changed = waitForChange(spState);
|
||||
spClient.dispatchTypedEvent("connect");
|
||||
expect((await changed).map((t) => t.name)).toEqual(["a"]);
|
||||
expect(spState.getPagination()).toEqual({
|
||||
nextCursor: "c1",
|
||||
pageCount: 1,
|
||||
});
|
||||
spState.destroy();
|
||||
});
|
||||
|
||||
it("does NOT load on connect when paginated mode is off", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
client.dispatchTypedEvent("connect");
|
||||
await Promise.resolve();
|
||||
expect(client.listTools).not.toHaveBeenCalled();
|
||||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,5 +138,23 @@ describe("useManagedPrompts", () => {
|
|||
const { result } = renderHook(() => useManagedPrompts(client, null));
|
||||
expect(result.current.listChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("clearListChanged() acknowledges the indicator without fetching (#1721)", async () => {
|
||||
const { result } = renderHook(() => useManagedPrompts(client, state));
|
||||
act(() => {
|
||||
client.dispatchTypedEvent("promptsListChanged");
|
||||
});
|
||||
await waitFor(() => expect(result.current.listChanged).toBe(true));
|
||||
act(() => {
|
||||
result.current.clearListChanged();
|
||||
});
|
||||
await waitFor(() => expect(result.current.listChanged).toBe(false));
|
||||
expect(client.listAllPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearListChanged() is a no-op when state is null", () => {
|
||||
const { result } = renderHook(() => useManagedPrompts(client, null));
|
||||
expect(() => result.current.clearListChanged()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -143,5 +143,23 @@ describe("useManagedResources", () => {
|
|||
const { result } = renderHook(() => useManagedResources(client, null));
|
||||
expect(result.current.listChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("clearListChanged() acknowledges the indicator without fetching (#1721)", async () => {
|
||||
const { result } = renderHook(() => useManagedResources(client, state));
|
||||
act(() => {
|
||||
client.dispatchTypedEvent("resourcesListChanged");
|
||||
});
|
||||
await waitFor(() => expect(result.current.listChanged).toBe(true));
|
||||
act(() => {
|
||||
result.current.clearListChanged();
|
||||
});
|
||||
await waitFor(() => expect(result.current.listChanged).toBe(false));
|
||||
expect(client.listAllResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearListChanged() is a no-op when state is null", () => {
|
||||
const { result } = renderHook(() => useManagedResources(client, null));
|
||||
expect(() => result.current.clearListChanged()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,6 +138,26 @@ describe("useManagedTools", () => {
|
|||
expect(result.current.listChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("clearListChanged() acknowledges the indicator without fetching (#1721)", async () => {
|
||||
const { result } = renderHook(() => useManagedTools(client, state));
|
||||
act(() => {
|
||||
client.dispatchTypedEvent("toolsListChanged");
|
||||
});
|
||||
await waitFor(() => expect(result.current.listChanged).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.clearListChanged();
|
||||
});
|
||||
await waitFor(() => expect(result.current.listChanged).toBe(false));
|
||||
// No list fetch happened — only the flag was cleared.
|
||||
expect(client.listAllTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearListChanged() is a no-op when state is null", () => {
|
||||
const { result } = renderHook(() => useManagedTools(client, null));
|
||||
expect(() => result.current.clearListChanged()).not.toThrow();
|
||||
});
|
||||
|
||||
it("keeps a change that fires during the awaited refresh (race regression)", async () => {
|
||||
const { result } = renderHook(() => useManagedTools(client, state));
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,20 @@ describe("usePagedPrompts", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("exposes nextCursor and pageCount from paginationChange", async () => {
|
||||
const { result } = renderHook(() => usePagedPrompts(client, state));
|
||||
expect(result.current.nextCursor).toBeUndefined();
|
||||
expect(result.current.pageCount).toBe(0);
|
||||
client.queuePromptPages({ prompts: [prompt("a")], nextCursor: "c1" });
|
||||
await act(async () => {
|
||||
await result.current.loadPage();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.nextCursor).toBe("c1");
|
||||
expect(result.current.pageCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("clear() proxies to the state", async () => {
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await state.loadPage();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,23 @@ describe("usePagedResources", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("exposes nextCursor and pageCount from paginationChange", async () => {
|
||||
const { result } = renderHook(() => usePagedResources(client, state));
|
||||
expect(result.current.nextCursor).toBeUndefined();
|
||||
expect(result.current.pageCount).toBe(0);
|
||||
client.queueResourcePages({
|
||||
resources: [resource("a://1")],
|
||||
nextCursor: "c1",
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.loadPage();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.nextCursor).toBe("c1");
|
||||
expect(result.current.pageCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("clear() proxies to the state", async () => {
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await state.loadPage();
|
||||
|
|
|
|||
|
|
@ -66,6 +66,28 @@ describe("usePagedTools", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("exposes nextCursor and pageCount from paginationChange", async () => {
|
||||
const { result } = renderHook(() => usePagedTools(client, state));
|
||||
expect(result.current.nextCursor).toBeUndefined();
|
||||
expect(result.current.pageCount).toBe(0);
|
||||
client.queueToolPages({ tools: [tool("a")], nextCursor: "c1" });
|
||||
await act(async () => {
|
||||
await result.current.loadPage();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.nextCursor).toBe("c1");
|
||||
expect(result.current.pageCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds pagination from the state's initial snapshot", async () => {
|
||||
client.queueToolPages({ tools: [tool("a")], nextCursor: "c1" });
|
||||
await state.loadPage();
|
||||
const { result } = renderHook(() => usePagedTools(client, state));
|
||||
expect(result.current.nextCursor).toBe("c1");
|
||||
expect(result.current.pageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("clear() proxies to the state", async () => {
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await state.loadPage();
|
||||
|
|
|
|||
|
|
@ -431,6 +431,7 @@ describe("useServers", () => {
|
|||
taskTtl: 30000,
|
||||
maxFetchRequests: 1000,
|
||||
autoRefreshOnListChanged: false,
|
||||
paginatedLists: false,
|
||||
roots: [],
|
||||
});
|
||||
});
|
||||
|
|
@ -518,6 +519,7 @@ describe("useServers", () => {
|
|||
maxFetchRequests: 1000,
|
||||
// Absent autoRefreshOnListChanged on disk reads back as false.
|
||||
autoRefreshOnListChanged: false,
|
||||
paginatedLists: false,
|
||||
// Absent roots on disk reads back as an empty list for the form.
|
||||
roots: [],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue