inspector/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
Cliff Hall b0a024269e
feat(web): list-changed handling — indicator, pull-on-demand default, and per-server auto-refresh opt-in (#1402) (#1443)
* feat(web): wire the listChanged indicator across all screens (#1402)

The per-screen list-changed indicator was hardcoded false in
InspectorView for Tools, Apps, Prompts, and Resources, so it could
never light up. Source it from the managed-state layer instead.

Each ManagedXState now tracks a listChanged flag: set true when its
*/list_changed notification arrives (the existing auto-refresh leaves
it set), exposed via getListChanged()/a listChangedChange event, and
cleared by clearListChanged() or on disconnect. The useManagedX hooks
surface the flag and clear it on a user-initiated refresh (the
indicator's Refresh button = acknowledge). App threads the three flags
to InspectorView, which maps them to the screens — Apps shares the
tools flag since apps are a filtered view of tools.

Tests cover set/clear/disconnect at the state-manager layer, the hook
subscription + refresh-clears behavior, and the InspectorView routing
(including Apps sharing the tools flag and no cross-wiring between
screens).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): close clear-after-await race in managed-list refresh (#1402 review)

The useManagedX refresh() callbacks cleared the listChanged flag AFTER
awaiting the fetch. If a */list_changed arrived mid-fetch, the state
re-set the flag (and auto-refreshed), then the trailing clear wiped that
genuinely-new signal. Clear BEFORE the await instead: the user's refresh
acknowledges only the change in hand, and a mid-fetch change re-sets the
flag and survives. Adds a race regression test on the tools hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): drop duplicate Refresh on the Apps screen (#1402)

AppControls uniquely had a standalone toolbar Refresh button AND the
ListChangedIndicator on a separate row, so once the indicator could
actually appear (this PR), the Apps screen showed two Refresh buttons.
Match the other three controls (Tools/Prompts/Resources): the indicator
is the single header-right refresh affordance. Updates AppControls and
AppsScreen tests to assert exactly one Refresh (and none when the list
hasn't changed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): stop auto-invoking */list on list_changed; user pulls via Refresh (#1402)

With auto-refresh on, the list was already current by the time the
list-changed indicator appeared, making its Refresh button moot. Now the
managed Tools/Prompts/Resources state managers only flag the change on
*/list_changed (they no longer auto-refresh); the list stays as-is until
the user clicks Refresh, which fetches the new list and clears the
indicator. The connect-time initial load is unchanged.

Resource templates (no own indicator, part of the Resources screen) also
stop auto-refreshing; onRefreshResources now refreshes both resources and
templates, since a single resources/list_changed covers both. Tasks are
left auto-refreshing — they have no list-changed indicator and need live
status updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(core): correct ManagedResourceTemplatesState header comments (#1402 review)

The class no longer subscribes to resourceTemplatesListChanged; update
the stale file/class docstrings that still claimed it syncs on that
notification to reflect connect + statusChange only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): add per-server 'Auto Refresh on List Changed Notifications' option (#1402)

New Options section in ServerSettingsForm with a single checkbox. When
checked, the managed Tools/Prompts/Resources (and resource templates)
state auto-refreshes on */list_changed notifications; when unchecked
(default) the notification only lights the list-changed indicator and the
user pulls via Refresh.

Plumbing: new optional autoRefreshOnListChanged on InspectorServerSettings
and StoredMCPServer, round-tripped through serverList (lifted with a false
default, persisted only when true, added to the exhaustive field-key map);
surfaced from InspectorClient via a new getServerSettings() on the protocol;
the state managers read it live at notification time to decide auto-refresh
vs flag-only. Resource templates re-subscribe to their list_changed but only
auto-refresh when the option is on (they have no indicator of their own).

Tests cover the round-trip (true persists, false omitted), the form
section (render/checked/toggle), and each state manager's on/off branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): persist autoRefreshOnListChanged through validateSettings (#1402)

The PUT /api/servers write path runs settings through validateSettings,
which rebuilds the value from explicitly-named fields and silently drops
unknown keys — so autoRefreshOnListChanged never reached disk (the
serverList round-trip test passed because it calls
inspectorSettingsToStoredFields directly, bypassing this path). Validate
it as an optional boolean and include it in the built value; false is
omitted on disk by inspectorSettingsToStoredFields as before.

Adds integration tests over the real PUT path: true persists, false is
omitted, and a non-boolean is rejected (400).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): default the Server Settings modal to the Options section (#1402)

Open 'Options' instead of 'Custom Headers' when the Server Settings modal
first opens, so the new Auto Refresh toggle is the first thing surfaced.
Updates the modal default-expanded section, the modal test's initial-state
assertions, the three header tests (now expand 'Custom Headers' first since
it's no longer open by default), and the story defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop accidentally-committed Inspector history dump; gitignore the class

83076441 swept an exported Inspector message-history JSON (manual-testing
artifact at the repo root) into the tree via git add -A. Untrack it and
extend the existing repo-root debug-artifact .gitignore block (alongside
/*.png) to cover /inspector-history-*.json so these can't be re-added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:58:21 -04:00

356 lines
13 KiB
TypeScript

import { createRef, useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import {
renderWithMantine,
screen,
within,
} from "../../../test/renderWithMantine";
import {
AppsScreen,
type AppsScreenProps,
type AppsUiState,
} from "./AppsScreen";
import { EMPTY_APPS_UI } from "../screenUiState";
import type {
AppRendererHandle,
BridgeFactory,
} from "../../elements/AppRenderer/AppRenderer";
const noFieldsApp: Tool = {
name: "ops",
title: "Ops Dashboard",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/ops" } },
};
const fieldedApp: Tool = {
name: "weather",
title: "Weather Widget",
inputSchema: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
},
required: ["city"],
},
_meta: { ui: { resourceUri: "ui://apps/weather" } },
};
const cohortApp: Tool = {
name: "cohorts",
title: "Cohort Data",
description: "Cohort retention",
inputSchema: {
type: "object",
properties: { metric: { type: "string", default: "retention" } },
},
_meta: { ui: { resourceUri: "ui://apps/cohorts" } },
};
const okBridgeFactory: BridgeFactory = () =>
({
sendToolInput: async () => {},
sendToolResult: async () => {},
sendToolCancelled: async () => {},
teardownResource: async () => ({}),
close: async () => {},
}) as unknown as AppBridge;
function buildProps(overrides: Partial<AppsScreenProps> = {}): AppsScreenProps {
return {
tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[],
listChanged: false,
// happy-dom would otherwise try to fetch the iframe `src` over the
// network. A data URL keeps the AppRenderer mountable without leaving
// the test environment.
sandboxPath: "data:text/html,<title>sandbox</title>",
bridgeFactory: okBridgeFactory,
rendererRef: createRef<AppRendererHandle>(),
ui: EMPTY_APPS_UI,
onUiChange: vi.fn(),
onRefreshList: vi.fn(),
onSelectApp: vi.fn(),
onOpenApp: vi.fn(),
onCloseApp: vi.fn(),
...overrides,
};
}
// AppsScreen lifts selection, form values, and the sidebar search to the
// parent (App) as one `ui` object so they persist across tab navigation
// (#1417), while `running`/`maximized` stay local to the screen. This host
// holds the lifted state so clicking an app, typing into its form/search, and
// closing drive the panel exactly as App owns it. The internal running/maximized
// state handles auto-launch, open, maximize, and back-to-input on its own. Props
// passed in override defaults; the stateful `ui` wiring is applied last so
// callers can still observe activity via the rendered state.
function ControlledAppsScreen(overrides: Partial<AppsScreenProps> = {}) {
const props = buildProps(overrides);
const [ui, setUi] = useState<AppsUiState>({
...EMPTY_APPS_UI,
...props.ui,
});
return (
<AppsScreen
{...props}
ui={ui}
onUiChange={(next) => {
setUi(next);
props.onUiChange(next);
}}
/>
);
}
describe("AppsScreen", () => {
it("renders the empty selection state", () => {
renderWithMantine(<AppsScreen {...buildProps()} />);
expect(screen.getByText("Select an app to view details")).toBeVisible();
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
});
it("shows 'No apps available' when the tool list is empty", () => {
renderWithMantine(<AppsScreen {...buildProps({ tools: [] })} />);
expect(screen.getByText("No apps available")).toBeInTheDocument();
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
});
it("renders the unavailable state when no sandbox path is provided", () => {
renderWithMantine(
<AppsScreen {...buildProps({ sandboxPath: undefined })} />,
);
expect(screen.getByText(/MCP Apps are unavailable/i)).toBeInTheDocument();
// The sidebar / app list is not rendered in the unavailable state.
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
});
it("surfaces a bridge factory failure via onError", async () => {
const user = userEvent.setup();
const onError = vi.fn();
const throwingFactory: BridgeFactory = () => {
throw new Error("no connected MCP client");
};
renderWithMantine(
<ControlledAppsScreen
bridgeFactory={throwingFactory}
onError={onError}
/>,
);
// The no-fields app auto-launches on selection, mounting the renderer,
// whose effect invokes the factory (which throws → routes to onError).
await user.click(screen.getByText("Ops Dashboard"));
await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
expect((onError.mock.calls[0][0] as Error).message).toContain(
"no connected MCP client",
);
});
it("filters the list via the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledAppsScreen />);
await user.type(screen.getByPlaceholderText("Search apps..."), "weather");
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument();
});
it("shows 'No matching apps' when search yields no results", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledAppsScreen />);
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
expect(screen.getByText("No matching apps")).toBeInTheDocument();
});
it("opens the detail panel when a fielded app is selected", async () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
const onOpenApp = vi.fn();
renderWithMantine(
<ControlledAppsScreen onSelectApp={onSelectApp} onOpenApp={onOpenApp} />,
);
await user.click(screen.getByText("Weather Widget"));
expect(onSelectApp).toHaveBeenCalledWith("weather");
expect(onOpenApp).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: /Open App/ }),
).toBeInTheDocument();
});
it("auto-launches a no-fields app on selection", async () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
await user.click(screen.getByText("Ops Dashboard"));
expect(onOpenApp).toHaveBeenCalledWith("ops", {});
// Renderer iframe replaces the form; Open App button is gone.
expect(
screen.queryByRole("button", { name: /Open App/ }),
).not.toBeInTheDocument();
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
});
it("invokes onOpenApp with form values when Open App is clicked", async () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
await user.click(screen.getByText("Weather Widget"));
const cityField = screen.getByRole("textbox", { name: /city/i });
await user.type(cityField, "Reykjavik");
await user.click(screen.getByRole("button", { name: /Open App/ }));
expect(onOpenApp).toHaveBeenCalledWith("weather", { city: "Reykjavik" });
expect(screen.getByTitle("Weather Widget")).toBeInTheDocument();
});
it("seeds schema defaults so untouched fields are sent on Open App", async () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
await user.click(screen.getByText("Cohort Data"));
// Open without editing the metric field: its default must still be sent.
await user.click(screen.getByRole("button", { name: /Open App/ }));
expect(onOpenApp).toHaveBeenCalledWith("cohorts", { metric: "retention" });
});
it("returns to the input form when 'Back to Input' is clicked", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledAppsScreen />);
await user.click(screen.getByText("Weather Widget"));
await user.type(
screen.getByRole("textbox", { name: /city/i }),
"Reykjavik",
);
await user.click(screen.getByRole("button", { name: /Open App/ }));
await user.click(screen.getByRole("button", { name: /Back to Input/ }));
expect(
screen.getByRole("button", { name: /Open App/ }),
).toBeInTheDocument();
});
it("does not show 'Back to Input' for a no-fields app", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledAppsScreen />);
await user.click(screen.getByText("Ops Dashboard"));
expect(
screen.queryByRole("button", { name: /Back to Input/ }),
).not.toBeInTheDocument();
});
it("toggles maximize, hiding the sidebar", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledAppsScreen />);
await user.click(screen.getByText("Ops Dashboard"));
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
await user.click(screen.getByLabelText("Maximize"));
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Restore"));
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
});
it("calls onCloseApp and clears selection on Close", async () => {
const user = userEvent.setup();
const onCloseApp = vi.fn();
renderWithMantine(<ControlledAppsScreen onCloseApp={onCloseApp} />);
await user.click(screen.getByText("Ops Dashboard"));
await user.click(screen.getByLabelText("Close"));
expect(onCloseApp).toHaveBeenCalledTimes(1);
expect(screen.getByText("Select an app to view details")).toBeVisible();
});
it("ignores re-clicking the same app (no duplicate onSelectApp)", async () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
renderWithMantine(<ControlledAppsScreen onSelectApp={onSelectApp} />);
// After the first click "Weather Widget" appears both in the sidebar
// list item and the right-pane header, so target the sidebar entry
// explicitly via the list-item button role.
const listItem = screen.getByRole("button", { name: /Weather Widget/ });
await user.click(listItem);
await user.click(listItem);
expect(onSelectApp).toHaveBeenCalledTimes(1);
});
it("resets form state when switching to a different app", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledAppsScreen />);
await user.click(screen.getByText("Weather Widget"));
await user.type(
screen.getByRole("textbox", { name: /city/i }),
"Reykjavik",
);
await user.click(screen.getByText("Cohort Data"));
// Cohort form is fresh; Reykjavik (the previous tool's input) is gone.
expect(screen.queryByDisplayValue("Reykjavik")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Open App/ }),
).toBeInTheDocument();
});
it("renders a single Refresh via the ListChangedIndicator when listChanged is true", async () => {
const user = userEvent.setup();
const onRefreshList = vi.fn();
renderWithMantine(
<AppsScreen {...buildProps({ listChanged: true, onRefreshList })} />,
);
// The indicator's Refresh is the only refresh affordance — no standalone
// toolbar button duplicating it.
expect(screen.getByText("List updated")).toBeInTheDocument();
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
expect(refreshButtons).toHaveLength(1);
await user.click(refreshButtons[0]);
expect(onRefreshList).toHaveBeenCalledTimes(1);
});
it("shows no Refresh button when listChanged is false", () => {
renderWithMantine(<AppsScreen {...buildProps()} />);
expect(
screen.queryByRole("button", { name: "Refresh" }),
).not.toBeInTheDocument();
});
it("renders the tool icon next to the header title when present", async () => {
const user = userEvent.setup();
const iconSrc = "data:image/svg+xml,%3Csvg/%3E";
const iconedApp: Tool = {
name: "weather-with-icon",
title: "Weather (Iconed)",
icons: [{ src: iconSrc }],
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/weather-iconed" } },
};
renderWithMantine(<ControlledAppsScreen tools={[iconedApp]} />);
await user.click(screen.getByText("Weather (Iconed)"));
const headerImg = screen
.getAllByRole("presentation")
.find((img) => img.getAttribute("src") === iconSrc);
expect(headerImg).toBeDefined();
});
it("ignores selection of an unknown tool name (defensive)", async () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
const { rerender } = renderWithMantine(
<ControlledAppsScreen onSelectApp={onSelectApp} />,
);
// Click an item, then re-render with a tools list that no longer
// contains it: the selection state stays put, but a follow-up click
// on the same name no-ops because the lookup fails.
await user.click(screen.getByText("Weather Widget"));
rerender(
<ControlledAppsScreen
onSelectApp={onSelectApp}
tools={[noFieldsApp, cohortApp]}
/>,
);
// Sidebar no longer shows Weather Widget; the right pane falls back
// to the empty selection state since the lookup misses.
expect(
within(screen.getByText("MCP Apps (2)").parentElement!).queryByText(
"Weather Widget",
),
).not.toBeInTheDocument();
expect(screen.getByText("Select an app to view details")).toBeVisible();
});
});