inspector/clients/tui/__tests__/InfoTab.test.tsx
Cliff Hall f43fafc08b
test(tui): extend coverage gate to Ink components + App.tsx (closes #1501) (#1538)
* test(tui): cover useSelectableList hook + leaf Ink components (#1501)

Add ink-testing-library and renderer-based tests for useSelectableList,
SelectableItem, and Tabs. Widen the vitest include glob to .test.tsx and
lift these files from the interim React-surface coverage exclusion. The
blanket src/components/**/*.tsx exclude is expanded into an explicit
per-file list so remaining components can be lifted incrementally.

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

* test(tui): add ink-scroll-view passthrough mock for renderer tests (#1501)

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

* test(tui): add ink-form passthrough mock for modal renderer tests (#1501)

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

* test(tui): cover all Ink tab + modal components via renderer tests (#1501)

Add ink-testing-library tests for the 12 Ink components (InfoTab, AuthTab,
ResourcesTab, PromptsTab, ToolsTab, NotificationsTab, HistoryTab,
RequestsTab, and the Tool/Resource/Prompt test modals + DetailsModal),
mounting them through the ink-scroll-view / ink-form passthrough doubles
and driving keypresses via stdin. Modal components render position:absolute
(empty frame under the test renderer) so their tests assert on behavior —
injected InspectorClient fakes and onClose — while the passthrough
ScrollView still mounts the inner JSX for coverage.

Lift these 12 components from the interim React-surface exclusion in
vitest.config.ts; only App.tsx remains excluded. All files clear the
uniform 90/90/90/90 per-file gate.

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

* test(tui): add App.tsx renderer-test harness + foundation tests (#1501)

Mock the full @inspector/core surface (InspectorClient, the 7 managed-state
classes, the 8 react hooks, transport + auth) behind a controllable harness,
plus the ink-scroll-view/ink-form passthroughs, so App renders under
ink-testing-library and its keyboard state machine can be driven via stdin.
Concurrent ink mounts share raw-mode stdin, so each render is unmounted in
afterEach. Foundation covers server-list nav, tab accelerators, connect/
disconnect, OAuth-tab gating, and connected-status rendering (~50% baseline).

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

* test(tui): cover App.tsx via mocked-core renderer tests; lift final exclusion (#1501)

Extend App.test.tsx to 67 tests driving the keyboard state machine, tab
content, modals, OAuth flows, and connect/disconnect across the controllable
@inspector/core mock. App.tsx now clears the 90/90/90/90 per-file gate
(94.6/90.6/94.5/96.6).

Remove the last React-surface exclusion (src/App.tsx) from
clients/tui/vitest.config.ts. The whole clients/tui/src tree is now under
the uniform per-file coverage gate with no React-surface exclusions,
completing the interim-scope lift from #1484.

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

* test(tui): raise stdout max-listeners in App test to silence warning (#1501)

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

* test(tui): raise App.tsx branch coverage >90 and de-flake coverage runs (#1501)

Add targeted App.tsx tests for the OAuth handlers' non-Error throw arms
(quick onCallback, guided start/advance, run-to-completion) and a re-entrant
quick-auth guard, lifting App.tsx branch coverage 90.6% -> 91.48% for a
safer margin above the 90 gate.

Harden the App renderer tests against the heavier v8 coverage instrumentation,
where React+ink's multi-macrotask render scheduling races fixed ticks:
- press() now settles two ticks per key so focus/tab changes flush before the
  next key is routed (ink re-subscribes useInput on re-render);
- expectFrame()/waitUntil() poll for frame content and async flow state
  (callbackServer opts) instead of asserting after a single fixed tick;
- ResourcesTab's content-clear test polls for fetched content likewise.

Plain 'npm test' (the CI path) was already stable; this makes the local
coverage gate stable too (13/13 clean runs vs ~2/8 flaky before).

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

* test(tui): de-flake component renderer tests under slow CI (#1501)

CI flaked on AuthTab's 'renders quick auth results' under the plain test run:
its oauthFlowState is populated by a post-mount useEffect reading the
inspectorClient, and a single-macrotask tick can race that effect -> setState
-> re-render chain on a slow/loaded CI runner.

Make each component test file's tick() flush several macrotask cycles so such
chains settle deterministically before assertions (no test relies on precise
tick timing — no fake timers / debounce). App.test.tsx already polls via
expectFrame/waitUntil and passed CI, so it's unchanged.

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

* test(tui): address review — shared setupFile for max-listeners, consume-once ink-form global (#1501)

Review follow-ups on #1538 (both non-blocking cleanups):

- Hoist process.std{out,in}.setMaxListeners into a shared vitest setupFile
  (__tests__/setup.ts) wired via setupFiles, so every renderer test file
  inherits it and the MaxListenersExceededWarning is silenced across the
  whole suite — not just App.test.tsx. Remove the per-file call.
- Make the ink-form double consume (clear) __INK_FORM_SUBMIT_VALUE__ on
  submit, eliminating the latent cross-test coupling on the shared global
  regardless of how the modal test files are organized.

No production code changed; suite + coverage gate stay green (App.tsx
94.8/91.5/94.5/96.6), and the warning no longer prints.

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-29 08:19:12 -04:00

401 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children. This passthrough
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import type {
MCPServerConfig,
ServerState,
} from "@inspector/core/mcp/index.js";
import { InfoTab } from "../src/components/InfoTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
// Real terminal escape sequences (with the leading ESC) so ink reliably parses
// them as arrow / page keys.
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const stdioConfig: MCPServerConfig = {
type: "stdio",
command: "node",
args: ["server.js", "--flag"],
env: { FOO: "bar", BAZ: "qux" },
cwd: "/tmp/work",
};
const baseState: ServerState = {
status: "disconnected",
error: null,
resources: [],
prompts: [],
tools: [],
stderrLogs: [],
};
describe("InfoTab", () => {
it("renders nothing beyond the header when serverName is null", () => {
const { lastFrame } = render(
<InfoTab
serverName={null}
serverConfig={null}
serverState={null}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Info");
expect(frame).not.toContain("Server Configuration");
});
it("renders 'No configuration available' when config is null", () => {
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={null}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Server Configuration");
expect(frame).toContain("No configuration available");
expect(frame).toContain("Server not connected");
});
it("renders a full stdio config (command, args, env, cwd)", () => {
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: stdio");
expect(frame).toContain("Command: node");
expect(frame).toContain("Args:");
expect(frame).toContain("server.js");
expect(frame).toContain("--flag");
expect(frame).toContain("Env:");
expect(frame).toContain("FOO=bar");
expect(frame).toContain("CWD: /tmp/work");
});
it("renders a stdio config with type omitted (undefined defaults to stdio)", () => {
const config: MCPServerConfig = {
command: "python",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: stdio");
expect(frame).toContain("Command: python");
// No args/env/cwd → those optional blocks are skipped
expect(frame).not.toContain("Args:");
expect(frame).not.toContain("Env:");
expect(frame).not.toContain("CWD:");
});
it("renders an sse config with headers", () => {
const config: MCPServerConfig = {
type: "sse",
url: "https://example.com/sse",
};
const withHeaders = {
...config,
headers: { Authorization: "Bearer x" },
} as unknown as MCPServerConfig;
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={withHeaders}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: sse");
expect(frame).toContain("URL: https://example.com/sse");
expect(frame).toContain("Headers:");
expect(frame).toContain("Authorization=Bearer x");
});
it("renders an sse config without headers", () => {
const config: MCPServerConfig = {
type: "sse",
url: "https://example.com/sse",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: sse");
expect(frame).not.toContain("Headers:");
});
it("renders a streamable-http config with headers", () => {
const config = {
type: "streamable-http",
url: "https://example.com/mcp",
headers: { "X-Key": "abc" },
} as unknown as MCPServerConfig;
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: streamable-http");
expect(frame).toContain("URL: https://example.com/mcp");
expect(frame).toContain("Headers:");
expect(frame).toContain("X-Key=abc");
});
it("renders a streamable-http config without headers", () => {
const config: MCPServerConfig = {
type: "streamable-http",
url: "https://example.com/mcp",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Type: streamable-http");
expect(frame).not.toContain("Headers:");
});
it("renders connected server information (name, version, instructions)", () => {
const state: ServerState = {
...baseState,
status: "connected",
serverInfo: { name: "Test Server", version: "1.2.3" },
instructions: "Use me wisely",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={state}
width={80}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Server Information");
expect(frame).toContain("Name: Test Server");
expect(frame).toContain("Version: 1.2.3");
expect(frame).toContain("Instructions:");
expect(frame).toContain("Use me wisely");
});
it("renders connected server info without optional fields", () => {
const state: ServerState = {
...baseState,
status: "connected",
serverInfo: { name: "", version: "" },
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={state}
width={80}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Server Information");
expect(frame).not.toContain("Name:");
expect(frame).not.toContain("Version:");
expect(frame).not.toContain("Instructions:");
});
it("does not render Server Information when connected but no serverInfo", () => {
const state: ServerState = {
...baseState,
status: "connected",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={state}
width={80}
height={30}
/>,
);
expect(lastFrame() ?? "").not.toContain("Server Information");
});
it("renders an error status with error message", () => {
const state: ServerState = {
...baseState,
status: "error",
error: "boom failed to connect",
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={null}
serverState={state}
width={80}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Error");
expect(frame).toContain("boom failed to connect");
});
it("renders an error status without an error message", () => {
const state: ServerState = {
...baseState,
status: "error",
error: null,
};
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={null}
serverState={state}
width={80}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("Error");
});
it("shows the footer and handles scroll keys when focused", async () => {
const { lastFrame, stdin } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={20}
focused
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("to scroll");
// Drive every useInput branch
stdin.write(""); // up arrow
await tick();
stdin.write(""); // down arrow
await tick();
stdin.write("[5~"); // pageUp
await tick();
stdin.write("[6~"); // pageDown
await tick();
// A non-handled key (no branch) to exercise the else fall-through
stdin.write("x");
await tick();
expect(lastFrame() ?? "").toContain("Info");
});
it("renders no config details for an unrecognized server type", () => {
// type is none of stdio / sse / streamable-http → the final `: null`
// ternary branch in the config block.
const config = {
type: "websocket",
url: "ws://example.com",
} as unknown as MCPServerConfig;
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={config}
serverState={baseState}
width={80}
height={20}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Server Configuration");
expect(frame).not.toContain("Type: stdio");
expect(frame).not.toContain("Type: sse");
expect(frame).not.toContain("Type: streamable-http");
});
it("handles scroll keys when focused but the scroll ref is null", async () => {
// serverName is null → no ScrollView mounts, so scrollViewRef.current is
// null. The handler is still active (isActive: focused), so the keys
// exercise the optional-chaining + `getViewportHeight() || 1` fallbacks.
const { stdin } = render(
<InfoTab
serverName={null}
serverConfig={null}
serverState={null}
width={80}
height={20}
focused
/>,
);
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP); // null ref → `|| 1`
await tick();
stdin.write(PAGE_DOWN); // null ref → `|| 1`
await tick();
expect(true).toBe(true);
});
it("does not show the footer when not focused", () => {
const { lastFrame } = render(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={20}
/>,
);
expect(lastFrame() ?? "").not.toContain("to scroll");
});
});