* fix(app): clear per-call panels and log level on disconnect (#1368) Session-scoped UI state owned by App.tsx — the Tools / Prompts / Resources result panels (`toolCallState` / `getPromptState` / `readResourceState`) and the optimistic `currentLogLevel` — survived a disconnect/reconnect cycle, so server B's screens showed server A's last result. Extend the existing `disconnect` listener to reset all four back to empty / "info", alongside the activeServerId + modal-flag resets it already does. This covers all three disconnect paths the listener handles (explicit toggle, header Disconnect button, mid-session transport failure). `latencyMs` continues to reset via the connectionStatus effect. Adds App.test.tsx covering the disconnect → tool-call-panel-cleared and log-level-reset transition, mocking the InspectorClient (a fake EventTarget), the per-server state managers, the core hooks, and InspectorView. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app): assert all four panels reset on disconnect (#1368) Address PR review: the disconnect test asserted only toolCallState and currentLogLevel. Extend the InspectorView double with prompt/resource status spans and get-prompt/read-resource buttons so the test fills and then asserts the reset of all four pieces of session-scoped state (tool / prompt / resource panels + log level), fully matching the "reset on disconnect" contract. Retitle the test accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c5e5ab754
commit
905b80a88c
2 changed files with 272 additions and 0 deletions
257
clients/web/src/App.test.tsx
Normal file
257
clients/web/src/App.test.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
renderWithMantine,
|
||||
screen,
|
||||
waitFor,
|
||||
act,
|
||||
} from "./test/renderWithMantine";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// App is a wiring component: it owns session-scoped UI state (the per-call
|
||||
// result panels and the optimistic log level) and resets it when the active
|
||||
// InspectorClient emits `disconnect`. These tests exercise that reset in
|
||||
// isolation by mocking the InspectorClient (a fake EventTarget we can fire
|
||||
// `disconnect` on), the per-server state managers, the core hooks, and the
|
||||
// InspectorView (a thin double that surfaces the props under test and lets us
|
||||
// trigger the handlers). See #1368.
|
||||
|
||||
// --- Fake InspectorClient ---------------------------------------------------
|
||||
// Extends EventTarget so the App's `addEventListener("disconnect", …)` wiring
|
||||
// is real; the test fires `dispatchEvent(new Event("disconnect"))` to simulate
|
||||
// any of the three disconnect paths (toggle, header button, transport failure).
|
||||
vi.mock("@inspector/core/mcp/index.js", () => {
|
||||
class FakeInspectorClient extends EventTarget {
|
||||
connect = vi.fn().mockResolvedValue(undefined);
|
||||
disconnect = vi.fn().mockResolvedValue(undefined);
|
||||
callTool = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, result: { acts: [] } });
|
||||
getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
|
||||
readResource = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ result: { contents: [] }, timestamp: 1 });
|
||||
setLoggingLevel = vi.fn().mockResolvedValue(undefined);
|
||||
getOAuthState = vi.fn().mockReturnValue(undefined);
|
||||
}
|
||||
const instances: FakeInspectorClient[] = [];
|
||||
return {
|
||||
InspectorClient: vi.fn(function () {
|
||||
const client = new FakeInspectorClient();
|
||||
instances.push(client);
|
||||
return client;
|
||||
}),
|
||||
// Test-only handle so the test can grab the live instance and fire events.
|
||||
__clientInstances: instances,
|
||||
};
|
||||
});
|
||||
|
||||
// Per-server state managers — App constructs nine of them per connect and
|
||||
// calls `destroy()` on teardown. Replace each with a no-op constructor.
|
||||
vi.mock("@inspector/core/mcp/state/managedToolsState.js", () => ({
|
||||
ManagedToolsState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/managedPromptsState.js", () => ({
|
||||
ManagedPromptsState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/managedResourcesState.js", () => ({
|
||||
ManagedResourcesState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/managedResourceTemplatesState.js", () => ({
|
||||
ManagedResourceTemplatesState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/managedRequestorTasksState.js", () => ({
|
||||
ManagedRequestorTasksState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/resourceSubscriptionsState.js", () => ({
|
||||
ResourceSubscriptionsState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/messageLogState.js", () => ({
|
||||
MessageLogState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/fetchRequestLogState.js", () => ({
|
||||
FetchRequestLogState: vi.fn(function () {
|
||||
return { destroy: vi.fn(), getFetchRequests: vi.fn(() => []) };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/stderrLogState.js", () => ({
|
||||
StderrLogState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@inspector/core/mcp/remote/index.js", () => ({
|
||||
RemoteInspectorClientStorage: vi.fn(function () {
|
||||
return { saveSession: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/environmentFactory", () => ({
|
||||
createWebEnvironment: vi.fn(() => ({ environment: {} })),
|
||||
}));
|
||||
|
||||
// --- Core hooks -------------------------------------------------------------
|
||||
// One server is available; the tools list carries the `get_acts` tool the
|
||||
// repro runs. Everything else returns empty.
|
||||
const SERVER_A = {
|
||||
id: "A",
|
||||
name: "PlotRocket",
|
||||
config: { type: "stdio", command: "node" },
|
||||
connection: { status: "disconnected" },
|
||||
};
|
||||
|
||||
vi.mock("@inspector/core/react/useServers.js", () => ({
|
||||
useServers: vi.fn(() => ({
|
||||
servers: [SERVER_A],
|
||||
addServer: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
updateServerSettings: vi.fn(),
|
||||
removeServer: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useInspectorClient.js", () => ({
|
||||
useInspectorClient: vi.fn(() => ({
|
||||
status: "connected",
|
||||
capabilities: {},
|
||||
clientCapabilities: {},
|
||||
// Left undefined so `initializeResult` stays undefined and the
|
||||
// ConnectionInfoModal (gated on it) never mounts during the test.
|
||||
serverInfo: undefined,
|
||||
instructions: undefined,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedTools.js", () => ({
|
||||
useManagedTools: vi.fn(() => ({
|
||||
tools: [{ name: "get_acts", inputSchema: { type: "object" } }],
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({
|
||||
useManagedPrompts: vi.fn(() => ({ prompts: [], refresh: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedResources.js", () => ({
|
||||
useManagedResources: vi.fn(() => ({ resources: [], refresh: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
|
||||
useManagedResourceTemplates: vi.fn(() => ({ resourceTemplates: [] })),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
|
||||
useManagedRequestorTasks: vi.fn(() => ({ tasks: [], refresh: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({
|
||||
useResourceSubscriptions: vi.fn(() => ({ subscriptions: [] })),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useMessageLog.js", () => ({
|
||||
useMessageLog: vi.fn(() => ({ messages: [] })),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({
|
||||
useFetchRequestLog: vi.fn(() => ({ fetchRequests: [] })),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({
|
||||
useSettingsDraft: vi.fn(() => ({
|
||||
draft: undefined,
|
||||
onChange: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// --- InspectorView double ---------------------------------------------------
|
||||
// Surfaces each piece of session-scoped state under test and exposes buttons
|
||||
// that invoke the App's connect / call-tool / get-prompt / read-resource /
|
||||
// set-log-level handlers.
|
||||
vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||
InspectorView: (props: {
|
||||
toolCallState?: { status?: string };
|
||||
getPromptState?: { status?: string };
|
||||
readResourceState?: { status?: string };
|
||||
currentLogLevel?: string;
|
||||
onToggleConnection: (id: string) => void;
|
||||
onCallTool: (name: string, args: Record<string, unknown>) => void;
|
||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSetLogLevel: (level: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="tool-status">
|
||||
{props.toolCallState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="prompt-status">
|
||||
{props.getPromptState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="resource-status">
|
||||
{props.readResourceState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="log-level">{props.currentLogLevel}</span>
|
||||
<button onClick={() => props.onToggleConnection("A")}>connect</button>
|
||||
<button onClick={() => props.onCallTool("get_acts", {})}>call</button>
|
||||
<button onClick={() => props.onGetPrompt("greet", {})}>get-prompt</button>
|
||||
<button onClick={() => props.onReadResource("res://x")}>
|
||||
read-resource
|
||||
</button>
|
||||
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import App from "./App";
|
||||
import * as McpIndex from "@inspector/core/mcp/index.js";
|
||||
|
||||
const clientInstances = (
|
||||
McpIndex as unknown as { __clientInstances: EventTarget[] }
|
||||
).__clientInstances;
|
||||
|
||||
describe("App session-scoped state reset on disconnect", () => {
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
});
|
||||
|
||||
it("clears the per-call panels and resets the log level on client disconnect", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
|
||||
// Connect: builds the InspectorClient and registers the disconnect listener.
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
// Fill all three per-call panels with their (resolved) results.
|
||||
await user.click(screen.getByText("call"));
|
||||
await user.click(screen.getByText("get-prompt"));
|
||||
await user.click(screen.getByText("read-resource"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("tool-status")).toHaveTextContent("ok");
|
||||
expect(screen.getByTestId("prompt-status")).toHaveTextContent("ok");
|
||||
expect(screen.getByTestId("resource-status")).toHaveTextContent("ok");
|
||||
});
|
||||
|
||||
// Bump the optimistic log level off its "info" default.
|
||||
await user.click(screen.getByText("set-level"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("log-level")).toHaveTextContent("debug"),
|
||||
);
|
||||
|
||||
// Disconnect: every panel empties and the level returns to "info".
|
||||
act(() => {
|
||||
clientInstances[0].dispatchEvent(new Event("disconnect"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("tool-status")).toHaveTextContent("none");
|
||||
expect(screen.getByTestId("prompt-status")).toHaveTextContent("none");
|
||||
expect(screen.getByTestId("resource-status")).toHaveTextContent("none");
|
||||
});
|
||||
expect(screen.getByTestId("log-level")).toHaveTextContent("info");
|
||||
});
|
||||
});
|
||||
|
|
@ -350,6 +350,15 @@ function App() {
|
|||
// failure / process exit) and avoids the first-render-clobbers-new-id
|
||||
// trap that watching connectionStatus has (status starts as
|
||||
// "disconnected" for the new client before connect() runs).
|
||||
//
|
||||
// This is also where session-scoped UI state that lives in App.tsx
|
||||
// (rather than inside the per-server state managers) gets reset, so the
|
||||
// next server's screens don't show server A's last result. The per-call
|
||||
// panels (`toolCallState` / `getPromptState` / `readResourceState`) and
|
||||
// the optimistic `currentLogLevel` all survive a disconnect/reconnect
|
||||
// cycle otherwise — see #1368. `latencyMs` resets via the
|
||||
// `connectionStatus` effect above instead because it has its own
|
||||
// connecting-edge ref to coordinate with.
|
||||
useEffect(() => {
|
||||
if (!inspectorClient) return;
|
||||
const onDisconnect = () => {
|
||||
|
|
@ -357,6 +366,12 @@ function App() {
|
|||
// Drop the open flag too — without this the modal would pop back the
|
||||
// next time `initializeResult` re-becomes truthy (e.g. reconnect).
|
||||
setConnectionInfoModalOpen(false);
|
||||
// Clear the per-call result panels so the next session starts empty.
|
||||
setToolCallState(undefined);
|
||||
setGetPromptState(undefined);
|
||||
setReadResourceState(undefined);
|
||||
// Back to the default level the next session begins at.
|
||||
setCurrentLogLevel("info");
|
||||
};
|
||||
inspectorClient.addEventListener("disconnect", onDisconnect);
|
||||
return () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue