feat(web): red border on a server card whose connection attempt failed (#1621)

Flags the server whose last connection attempt failed with a red card
border, cleared when another server is connected or a new connection is
attempted — a persistent, at-a-glance companion to the failure toast and
the auto-opened monitoring column.

- App tracks `failedServerId`: set in onToggleConnection's non-auth
  handshake catch, cleared at the start of any new connect attempt. It is
  deliberately kept out of resetSessionScopedUiState, since the failure's
  own `disconnect` event would otherwise clear it the same tick (that same
  event clears `activeServer`, which is why the flag is tracked separately).
- New `errored` Card theme variant (red border via a new
  `--inspector-error-border` token), mirroring the `highlighted` green
  border. Precedence: disabled (dimmed) > errored > highlighted.
- Plumbed `erroredServerId` through InspectorView → ServerListScreen →
  ServerCard (`errored` prop).

Full `npm run ci` passes (validate + coverage + smoke + storybook).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
cliffhall 2026-07-09 14:48:17 -04:00
commit c6999dff96

View file

@ -44,8 +44,18 @@ const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() }));
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
// When set, the next `connect()` rejects with this value (one-shot), so a test
// can exercise the handshake-failure path. Cleared after it fires.
let nextConnectRejection: unknown = null;
class FakeInspectorClient extends EventTarget {
connect = vi.fn().mockResolvedValue(undefined);
connect = vi.fn(() => {
if (nextConnectRejection !== null) {
const err = nextConnectRejection;
nextConnectRejection = null;
return Promise.reject(err);
}
return Promise.resolve(undefined);
});
disconnect = vi.fn().mockResolvedValue(undefined);
callTool = vi
.fn()
@ -88,6 +98,10 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
}),
// Test-only handle so the test can grab the live instance and fire events.
__clientInstances: instances,
// Test-only: arm the next connect() to reject (handshake-failure path).
__rejectNextConnect: (err: unknown) => {
nextConnectRejection = err;
},
};
});
@ -263,6 +277,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
readResourceState?: { status?: string };
currentLogLevel?: string;
activeTab?: string;
erroredServerId?: string;
onActiveTabChange: (tab: string) => void;
onToggleConnection: (id: string) => void;
onToolsUiChange: (next: {
@ -322,6 +337,9 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</span>
<span data-testid="log-level">{props.currentLogLevel}</span>
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
<span data-testid="errored-server">
{props.erroredServerId ?? "none"}
</span>
<button onClick={() => props.onActiveTabChange("Servers")}>
switch-servers-tab
</button>
@ -450,10 +468,58 @@ const clientInstances = (
McpIndex as unknown as { __clientInstances: EventTarget[] }
).__clientInstances;
const rejectNextConnect = (
McpIndex as unknown as { __rejectNextConnect: (err: unknown) => void }
).__rejectNextConnect;
const fetchLogInstances = (
FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] }
).__fetchLogInstances;
describe("App failed-connection card border (#1621)", () => {
beforeEach(() => {
clientInstances.length = 0;
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
});
it("flags the server whose connect attempt fails as erroredServerId", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
expect(screen.getByTestId("errored-server")).toHaveTextContent("none");
// Arm the next connect() to reject with a plain (non-auth) handshake error.
rejectNextConnect(new Error("spawn failed"));
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
);
});
it("clears the flag when a new connection attempt starts", async () => {
// Report status "error" so the *second* connect click is treated as a fresh
// attempt (not a disconnect of a live session), exercising the clear.
vi.mocked(useInspectorClient).mockReturnValue({
...DEFAULT_USE_INSPECTOR_CLIENT,
status: "error",
});
const user = userEvent.setup();
renderWithMantine(<App />);
rejectNextConnect(new Error("spawn failed"));
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
);
// A new attempt (this one resolves) clears the red-border flag.
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("none"),
);
});
});
describe("App session-scoped state reset on disconnect", () => {
beforeEach(() => {
clientInstances.length = 0;