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>
This commit is contained in:
Cliff Hall 2026-06-29 08:19:12 -04:00 • committed by GitHub
commit f43fafc08b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 5252 additions and 28 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,622 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import {
EMPTY_OAUTH_FLOW_STATE,
type OAuthFlowState,
} from "@inspector/core/auth/index.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
// 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 { AuthTab } from "../src/components/AuthTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write.
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));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const LEFT = `${ESC}[D`;
const RIGHT = `${ESC}[C`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
/** Minimal fake InspectorClient that only implements the surface AuthTab uses. */
function makeClient(state?: OAuthFlowState) {
const listeners = new Map<string, Set<() => void>>();
const client = {
getOAuthFlowState: () => state,
addEventListener: (event: string, fn: () => void) => {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(fn);
},
removeEventListener: (event: string, fn: () => void) => {
listeners.get(event)?.delete(fn);
},
};
const fire = (event: string) => {
listeners.get(event)?.forEach((fn) => fn());
};
return {
client: client as unknown as InspectorClient,
fire,
listeners,
};
}
const flow = (over: Partial<OAuthFlowState>): OAuthFlowState => ({
...EMPTY_OAUTH_FLOW_STATE,
...over,
});
// A state at "complete" with every detail field populated so getStepDetails
// returns a non-null value for every step (all rendered as completed).
const completeState = flow({
execution: "guided",
oauthStep: "complete",
resourceMetadata: {
resource: "https://api.example.com",
} as OAuthFlowState["resourceMetadata"],
oauthMetadata: {
issuer: "https://issuer.example.com",
} as OAuthFlowState["oauthMetadata"],
oauthClientInfo: {
client_id: "abc123",
} as OAuthFlowState["oauthClientInfo"],
authorizationUrl: new URL("https://auth.example.com/authorize?x=1"),
authorizationCode: "code-abcdef1234567890",
oauthTokens: {
access_token: "tok-abcdefghijklmnopqrstuvwxyz",
token_type: "Bearer",
},
});
const baseProps = {
serverName: "srv" as string | null,
serverConfig: null,
width: 120,
height: 30,
isOAuthCapable: true,
selectedAction: "guided" as "guided" | "quick" | "clear",
onSelectedActionChange: vi.fn(),
onQuickAuth: vi.fn(async () => {}),
onGuidedStart: vi.fn(async () => {}),
onGuidedAdvance: vi.fn(async () => {}),
onRunGuidedToCompletion: vi.fn(async () => {}),
onClearOAuth: vi.fn(),
};
describe("AuthTab", () => {
it("renders the placeholder when there is no server", () => {
const { lastFrame } = render(
<AuthTab
{...baseProps}
serverName={null}
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
/>,
);
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
});
it("renders the placeholder when the server is not OAuth-capable", () => {
const { lastFrame } = render(
<AuthTab
{...baseProps}
isOAuthCapable={false}
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
/>,
);
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
});
it("renders the guided action bar, hint, and progress (unfocused)", async () => {
const { client } = makeClient(completeState);
// tall enough that no step detail is clipped by the fixed-height Box
const { lastFrame } = render(
<AuthTab
{...baseProps}
height={80}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Authentication");
expect(frame).toContain("uided Auth");
expect(frame).toContain("uick Auth");
expect(frame).toContain("Clear OAuth");
expect(frame).toContain("Press [Space] to advance one step");
expect(frame).toContain("Press [Enter] to run guided auth to completion");
expect(frame).toContain("Guided OAuth Flow Progress");
// every step label, all completed (✓)
expect(frame).toContain("Metadata Discovery");
expect(frame).toContain("Client Registration");
expect(frame).toContain("Preparing Authorization");
expect(frame).toContain("Request Authorization Code");
expect(frame).toContain("Token Request");
expect(frame).toContain("Authentication Complete");
expect(frame).toContain("✓");
// completed detail strings from getStepDetails
expect(frame).toContain("Resource:");
expect(frame).toContain("OAuth:");
expect(frame).toContain("Code received:");
expect(frame).toContain("Exchanging code for tokens...");
expect(frame).toContain("Tokens: access_token=");
// no focused footer
expect(frame).not.toContain("select, G/Q/S or Enter run");
});
it("renders an in-progress step (cyan →) and not-started steps (○)", async () => {
const midState = flow({
execution: "guided",
oauthStep: "client_registration",
oauthClientInfo: {
client_id: "mid-client",
} as OAuthFlowState["oauthClientInfo"],
});
const { client } = makeClient(midState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("(in progress)");
expect(frame).toContain("→");
expect(frame).toContain("○");
// in-progress detail (client_registration → oauthClientInfo JSON)
expect(frame).toContain("mid-client");
});
it("renders the 'authorization URL opened' block when awaiting an auth code", async () => {
const awaitingState = flow({
execution: "guided",
oauthStep: "authorization_code",
authorizationUrl: new URL("https://auth.example.com/go?code=here"),
});
const { client } = makeClient(awaitingState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Authorization URL opened in browser");
expect(frame).toContain("auth.example.com/go");
expect(frame).toContain("Complete authorization in the browser");
});
it("covers metadata details when only the resource metadata is present", async () => {
const resourceOnly = flow({
oauthStep: "complete",
resourceMetadata: {
resource: "https://only-resource.example.com",
} as OAuthFlowState["resourceMetadata"],
});
const { client } = makeClient(resourceOnly);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Resource:");
expect(frame).not.toContain("OAuth: {");
});
it("covers metadata details when only the oauth metadata is present", async () => {
const oauthOnly = flow({
oauthStep: "complete",
oauthMetadata: {
issuer: "https://only-issuer.example.com",
} as OAuthFlowState["oauthMetadata"],
});
const { client } = makeClient(oauthOnly);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
expect(lastFrame() ?? "").toContain("OAuth:");
});
it("renders guided progress with no details when the flow state is empty", () => {
const { client } = makeClient(flow({}));
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Guided OAuth Flow Progress");
expect(frame).not.toContain("Resource:");
});
it("renders guided progress with no inspector client (no flow state)", () => {
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Guided OAuth Flow Progress");
expect(frame).toContain("○");
});
it("renders the quick hint and 'Authenticating...' status", () => {
const { client } = makeClient(flow({ execution: "quick" }));
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="quick"
inspectorClient={client}
oauthStatus="authenticating"
oauthMessage={null}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Press [Enter] to run quick auth");
expect(frame).toContain("Authenticating...");
});
it("renders the quick error message", () => {
const { client } = makeClient(flow({ execution: "quick" }));
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="quick"
inspectorClient={client}
oauthStatus="error"
oauthMessage="Something went wrong"
/>,
);
expect(lastFrame() ?? "").toContain("Something went wrong");
});
it("renders quick auth results with client info and tokens", async () => {
const quickSuccess = flow({
execution: "quick",
oauthClientInfo: {
client_id: "quick-client",
} as OAuthFlowState["oauthClientInfo"],
oauthTokens: {
access_token: "quick-token-abcdefghijklmnop",
token_type: "Bearer",
},
});
const { client } = makeClient(quickSuccess);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="quick"
inspectorClient={client}
oauthStatus="success"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Quick Auth Results");
expect(frame).toContain("quick-client");
expect(frame).toContain("Access Token:");
expect(frame).toContain("quick-token-abcdefgh");
});
it("renders the clear hint and the confirmation after pressing Enter", async () => {
const onClearOAuth = vi.fn();
const { client } = makeClient(flow({}));
const { lastFrame, stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="clear"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onClearOAuth={onClearOAuth}
/>,
);
expect(lastFrame() ?? "").toContain("Press [Enter] to clear OAuth state");
// a leading no-op key absorbs any dropped first keypress
stdin.write("x");
await tick();
stdin.write("\r");
await tick();
await tick();
expect(onClearOAuth).toHaveBeenCalled();
expect(lastFrame() ?? "").toContain("OAuth state cleared.");
});
it("shows the focused footer and header highlight when focused", () => {
const { client } = makeClient(completeState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
expect(lastFrame() ?? "").toContain("select, G/Q/S or Enter run");
});
it("selects actions via G/Q/S keys when focused", async () => {
const onSelectedActionChange = vi.fn();
const { client } = makeClient(flow({}));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write("g");
await tick();
stdin.write("q");
await tick();
stdin.write("s");
await tick();
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
});
it("cycles selection with left/right arrows from 'guided'", async () => {
const onSelectedActionChange = vi.fn();
const { client } = makeClient(flow({}));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write(LEFT);
await tick();
stdin.write(RIGHT);
await tick();
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
});
it("cycles selection with left/right arrows from 'quick'", async () => {
const onSelectedActionChange = vi.fn();
const { client } = makeClient(flow({ execution: "quick" }));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="quick"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write(LEFT);
await tick();
stdin.write(RIGHT);
await tick();
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
});
it("cycles selection with left/right arrows from 'clear'", async () => {
const onSelectedActionChange = vi.fn();
const { client } = makeClient(flow({}));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="clear"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write(LEFT);
await tick();
stdin.write(RIGHT);
await tick();
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
});
it("scrolls with up/down/pageUp/pageDown when focused", async () => {
const { client } = makeClient(completeState);
const { lastFrame, stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
// still rendered (scroll stubs are no-ops)
expect(lastFrame() ?? "").toContain("Guided OAuth Flow Progress");
});
it("runs guided to completion on Enter when 'guided' is selected", async () => {
const onRunGuidedToCompletion = vi.fn(async () => {});
const { client } = makeClient(flow({}));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onRunGuidedToCompletion={onRunGuidedToCompletion}
/>,
);
// a leading no-op key absorbs any dropped first keypress
stdin.write("x");
await tick();
stdin.write("\r");
await tick();
expect(onRunGuidedToCompletion).toHaveBeenCalled();
});
it("runs quick auth on Enter when 'quick' is selected", async () => {
const onQuickAuth = vi.fn(async () => {});
const { client } = makeClient(flow({ execution: "quick" }));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="quick"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onQuickAuth={onQuickAuth}
/>,
);
stdin.write("x");
await tick();
stdin.write("\r");
await tick();
expect(onQuickAuth).toHaveBeenCalled();
});
it("advances guided one step on Space (start then advance)", async () => {
const onGuidedStart = vi.fn(async () => {});
const onGuidedAdvance = vi.fn(async () => {});
// mid-flow state so needsAuthCode/isComplete are both false on advance
const { client } = makeClient(
flow({ execution: "guided", oauthStep: "client_registration" }),
);
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onGuidedStart={onGuidedStart}
onGuidedAdvance={onGuidedAdvance}
/>,
);
// first space starts the flow
stdin.write(" ");
await tick();
// second space advances one step
stdin.write(" ");
await tick();
// third space (in case the first was dropped) keeps both reachable
stdin.write(" ");
await tick();
expect(onGuidedStart).toHaveBeenCalled();
expect(onGuidedAdvance).toHaveBeenCalled();
});
it("does not act on input when not OAuth-capable but focused", async () => {
const onSelectedActionChange = vi.fn();
const { stdin, lastFrame } = render(
<AuthTab
{...baseProps}
focused
isOAuthCapable={false}
selectedAction="guided"
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write("g");
await tick();
expect(onSelectedActionChange).not.toHaveBeenCalled();
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
});
it("subscribes to oauth events and refreshes when they fire", async () => {
const { client, fire, listeners } = makeClient(completeState);
const { unmount } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
expect(listeners.get("oauthStepChange")?.size).toBe(1);
expect(listeners.get("oauthComplete")?.size).toBe(1);
// firing the listeners runs the update() callback
fire("oauthStepChange");
fire("oauthComplete");
await tick();
// unmount runs the cleanup (removeEventListener)
unmount();
expect(listeners.get("oauthStepChange")?.size).toBe(0);
expect(listeners.get("oauthComplete")?.size).toBe(0);
});
});

View file

@ -0,0 +1,102 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import { Text } from "ink";
// ScrollView: passthrough so `content` mounts and the imperative ref API
// (scrollBy / getViewportHeight) exists for the scroll-key handlers.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { DetailsModal } from "../src/components/DetailsModal.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write.
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));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
describe("DetailsModal", () => {
it("renders without crashing with content", () => {
const { unmount } = render(
<DetailsModal
title="Details"
content={<Text>some content</Text>}
width={120}
height={30}
onClose={() => {}}
/>,
);
// Modal is position="absolute" so lastFrame is empty; just confirm it
// mounted and unmounts cleanly (running the resize cleanup effect).
unmount();
});
it("handles all scroll keys via the ScrollView ref", async () => {
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>scrollable</Text>}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
});
it("ignores keys it does not handle", async () => {
const onClose = vi.fn();
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>x</Text>}
width={120}
height={30}
onClose={onClose}
/>,
);
await tick();
// A plain character key matches none of the branches.
stdin.write("a");
await tick();
expect(onClose).not.toHaveBeenCalled();
});
it("calls onClose on ESC", async () => {
const onClose = vi.fn();
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>x</Text>}
width={120}
height={30}
onClose={onClose}
/>,
);
await tick();
stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,275 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { MessageEntry } from "@inspector/core/mcp/index.js";
// 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 { HistoryTab } from "../src/components/HistoryTab.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 so ink 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 ts = new Date("2024-01-01T12:34:56Z");
const entry = (over: Partial<MessageEntry>): MessageEntry =>
({
id: "id",
timestamp: ts,
direction: "request",
message: { jsonrpc: "2.0", id: 1, method: "ping" },
...over,
}) as unknown as MessageEntry;
// One entry exercising each label / direction / detail branch.
const reqWithResponse = entry({
id: "m0",
direction: "request",
message: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} },
response: { jsonrpc: "2.0", id: 1, result: { tools: [] } },
duration: 5,
});
const reqPending = entry({
id: "m1",
direction: "request",
message: { jsonrpc: "2.0", id: 2, method: "tools/call" },
});
const respResult = entry({
id: "m2",
direction: "response",
message: { jsonrpc: "2.0", id: 3, result: { ok: true } },
});
const respError = entry({
id: "m3",
direction: "response",
message: { jsonrpc: "2.0", id: 4, error: { code: -32601, message: "no" } },
});
const respPlain = entry({
id: "m4",
direction: "response",
message: { jsonrpc: "2.0", id: 5 },
});
const notification = entry({
id: "m5",
direction: "notification",
message: { jsonrpc: "2.0", method: "notifications/message" },
});
const unknownEntry = entry({
id: "m6",
direction: "notification",
message: { jsonrpc: "2.0" },
});
const allMessages: MessageEntry[] = [
reqWithResponse,
reqPending,
respResult,
respError,
respPlain,
notification,
unknownEntry,
];
describe("HistoryTab", () => {
it("renders the empty state when there are no messages", () => {
const onCountChange = vi.fn();
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[]}
width={120}
height={30}
onCountChange={onCountChange}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Messages (0)");
expect(frame).toContain("No messages");
expect(frame).toContain("Select a message to view details");
expect(onCountChange).toHaveBeenCalledWith(0);
});
it("renders every list-label and direction-symbol variant", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Messages (7)");
// request with response → "✓"; pending request → "..."
expect(frame).toContain("→ tools/list ✓");
expect(frame).toContain("→ tools/call ...");
// response labels
expect(frame).toContain("← Response (result)");
expect(frame).toContain("← Response (error: -32601)");
expect(frame).toContain("← Response");
// notification + unknown
expect(frame).toContain("• notifications/message");
expect(frame).toContain("• Unknown");
expect(frame).toContain("▶ ");
});
it("renders request details with a response section and duration", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[reqWithResponse]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: request");
expect(frame).toContain("(5ms)");
expect(frame).toContain("Request:");
expect(frame).toContain("Response:");
});
it("renders the waiting-for-response placeholder for a pending request", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[reqPending]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Request:");
expect(frame).toContain("Waiting for response...");
});
it("renders response details with a Response label and Response header", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[respResult]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: response");
expect(frame).toContain("Response:");
});
it("renders notification details with a Notification label", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[notification]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: notification");
expect(frame).toContain("Notification:");
// header uses the notification method
expect(frame).toContain("notifications/message");
});
it("falls back to the Message header for a methodless notification", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[unknownEntry]}
width={120}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("Message");
});
it("moves selection with arrows and page keys when the list is focused", async () => {
const { lastFrame, stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={12}
focusedPane="messages"
/>,
);
// up at top boundary: no movement
stdin.write(UP);
await tick();
// down to the next message
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("Direction: request");
// pageDown jumps toward the end, pageUp back toward the start
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
// up to move back toward the top
stdin.write(UP);
await tick();
expect(lastFrame() ?? "").toContain("Messages (7)");
});
it("handles details-pane scrolling, footer, and zoom shortcut", async () => {
const onViewDetails = vi.fn();
const { lastFrame, stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
/>,
);
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith(allMessages[0]);
});
it("does not fire input handlers when a modal is open", async () => {
const onViewDetails = vi.fn();
const { stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
modalOpen={true}
/>,
);
stdin.write("+");
await tick();
expect(onViewDetails).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,401 @@
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");
});
});

View file

@ -0,0 +1,184 @@
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 { StderrLogEntry } from "@inspector/core/mcp/index.js";
import { NotificationsTab } from "../src/components/NotificationsTab.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 for this component's useInput handler.
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const makeLog = (message: string): StderrLogEntry => ({
timestamp: new Date("2026-06-27T12:34:56Z"),
message,
});
describe("NotificationsTab", () => {
it("renders the empty state when there are no logs", () => {
const { lastFrame } = render(
<NotificationsTab stderrLogs={[]} width={80} height={20} />,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Logging (0)");
expect(frame).toContain("No stderr output yet");
});
it("renders log entries with timestamps and messages", () => {
const logs = [makeLog("first error"), makeLog("second error")];
const { lastFrame } = render(
<NotificationsTab stderrLogs={logs} width={80} height={20} />,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Logging (2)");
expect(frame).toContain("first error");
expect(frame).toContain("second error");
expect(frame).not.toContain("No stderr output yet");
});
it("invokes onCountChange with the initial log count", () => {
const onCountChange = vi.fn();
render(
<NotificationsTab
stderrLogs={[makeLog("a"), makeLog("b")]}
width={80}
height={20}
onCountChange={onCountChange}
/>,
);
expect(onCountChange).toHaveBeenCalledWith(2);
});
it("re-invokes onCountChange when the log count changes", async () => {
const onCountChange = vi.fn();
const { rerender } = render(
<NotificationsTab
stderrLogs={[makeLog("a")]}
width={80}
height={20}
onCountChange={onCountChange}
/>,
);
expect(onCountChange).toHaveBeenLastCalledWith(1);
rerender(
<NotificationsTab
stderrLogs={[makeLog("a"), makeLog("b"), makeLog("c")]}
width={80}
height={20}
onCountChange={onCountChange}
/>,
);
await tick();
expect(onCountChange).toHaveBeenLastCalledWith(3);
});
it("picks up a changed onCountChange callback via the ref effect", async () => {
const first = vi.fn();
const second = vi.fn();
const { rerender } = render(
<NotificationsTab
stderrLogs={[makeLog("a")]}
width={80}
height={20}
onCountChange={first}
/>,
);
expect(first).toHaveBeenLastCalledWith(1);
// New callback identity + new count → the ref is updated, then the
// count-change effect fires the latest callback.
rerender(
<NotificationsTab
stderrLogs={[makeLog("a"), makeLog("b")]}
width={80}
height={20}
onCountChange={second}
/>,
);
await tick();
expect(second).toHaveBeenLastCalledWith(2);
});
it("renders without an onCountChange prop", () => {
const { lastFrame } = render(
<NotificationsTab
stderrLogs={[makeLog("solo")]}
width={80}
height={20}
/>,
);
expect(lastFrame() ?? "").toContain("solo");
});
it("highlights the header and handles scroll keys when focused", async () => {
const logs = [makeLog("line one"), makeLog("line two")];
const { lastFrame, stdin } = render(
<NotificationsTab stderrLogs={logs} width={80} height={20} focused />,
);
expect(lastFrame() ?? "").toContain("Logging (2)");
// Drive every useInput branch
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write("x"); // non-handled key → else fall-through
await tick();
expect(lastFrame() ?? "").toContain("line one");
});
it("handles scroll keys with no logs (ScrollView absent → null scroll ref)", async () => {
// With an empty log list the ScrollView (and its ref) is never mounted, so
// scrollViewRef.current is null. Driving the scroll keys exercises the
// optional-chaining + `getViewportHeight() || 1` fallback paths.
const { lastFrame, stdin } = render(
<NotificationsTab stderrLogs={[]} width={80} height={20} focused />,
);
expect(lastFrame() ?? "").toContain("No stderr output yet");
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
expect(lastFrame() ?? "").toContain("No stderr output yet");
});
it("does not react to keys when not focused", async () => {
const logs = [makeLog("line one")];
const { lastFrame, stdin } = render(
<NotificationsTab stderrLogs={logs} width={80} height={20} />,
);
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("line one");
});
});

View file

@ -0,0 +1,344 @@
import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
// ScrollView: passthrough so the results JSX actually mounts (and is counted
// for coverage) and the imperative ref API exists for the scroll handlers.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
// Form: a double that fires onSubmit when the user presses Enter ("\r").
vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
import { PromptTestModal } from "../src/components/PromptTestModal.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and let the async getPrompt promise + setState settle.
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));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const ENTER = "\r";
const makePrompt = (over: Partial<Prompt> = {}): Prompt =>
({
name: "alpha",
description: "First prompt",
arguments: [{ name: "topic", description: "a topic", required: true }],
...over,
}) as unknown as Prompt;
// Set the value the mock Form submits on Enter; reset afterward so cases
// don't leak into one another.
function setFormSubmitValue(value: Record<string, string> | undefined) {
if (value === undefined) {
delete (globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__;
} else {
(globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__ = value;
}
}
afterEach(() => {
setFormSubmitValue(undefined);
vi.restoreAllMocks();
});
describe("PromptTestModal", () => {
it("submits the form, calls getPrompt, and shows results (success path)", async () => {
const getPrompt = vi.fn().mockResolvedValue({
result: {
description: "ok",
messages: [{ role: "user", content: { type: "text", text: "hello" } }],
},
});
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const prompt = makePrompt();
// Submit a non-empty value so the "Arguments:" block (input length > 0)
// is rendered in the results view.
setFormSubmitValue({ topic: "weather" });
const { stdin } = render(
<PromptTestModal
prompt={prompt}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledWith("alpha", { topic: "weather" });
// Now in results mode — drive every scroll key branch.
stdin.write(DOWN);
await tick();
stdin.write(UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
});
it("renders the loading state while getPrompt is pending", async () => {
let resolveGetPrompt!: (value: { result: { messages: unknown[] } }) => void;
const getPrompt = vi.fn().mockReturnValue(
new Promise<{ result: { messages: unknown[] } }>((resolve) => {
resolveGetPrompt = resolve;
}),
);
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
// One tick: state has transitioned to "loading" but the promise is still
// pending, so the loading JSX is mounted.
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
// Now let it resolve and settle into results.
resolveGetPrompt({ result: { messages: [] } });
await tick();
await tick();
});
it("renders results with no arguments block when submitted value is empty", async () => {
const getPrompt = vi.fn().mockResolvedValue({
result: { messages: [] },
});
const inspectorClient = { getPrompt } as unknown as InspectorClient;
// Default submit value is {} → Object.keys(input).length === 0 branch.
const { stdin } = render(
<PromptTestModal
prompt={makePrompt({ description: undefined })}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("shows error results when getPrompt rejects with an Error", async () => {
const getPrompt = vi.fn().mockRejectedValue(new Error("boom"));
const inspectorClient = { getPrompt } as unknown as InspectorClient;
setFormSubmitValue({ topic: "x" });
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
// Scroll through the error results to exercise the results scroll branches.
stdin.write(DOWN);
await tick();
});
it("handles a rejected getPrompt that throws a string", async () => {
const getPrompt = vi.fn().mockRejectedValue("string failure");
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("handles a rejected getPrompt that throws an object with a message", async () => {
const getPrompt = vi
.fn()
.mockRejectedValue({ message: "obj msg", code: 7 });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("handles a rejected getPrompt that throws an object without a message", async () => {
const getPrompt = vi.fn().mockRejectedValue({ code: 99 });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("handles a rejected getPrompt that throws a non-object, non-string value", async () => {
const getPrompt = vi.fn().mockRejectedValue(42);
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(getPrompt).toHaveBeenCalledTimes(1);
});
it("does nothing when inspectorClient is null (early return)", async () => {
const onClose = vi.fn();
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={null}
width={120}
height={30}
onClose={onClose}
/>,
);
await tick();
// Submitting the form hits handleFormSubmit which returns early because
// inspectorClient is null — state stays "form", no crash.
stdin.write(ENTER);
await tick();
await tick();
// Still in form mode: a non-escape key is ignored by the form-state branch.
stdin.write("a");
await tick();
expect(onClose).not.toHaveBeenCalled();
});
it("closes on ESC and resets state", async () => {
const onClose = vi.fn();
const getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={onClose}
/>,
);
await tick();
// Get into results mode first so the escape-from-results path runs.
stdin.write(ENTER);
await tick();
await tick();
stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("unmounts cleanly, running cleanup effects", async () => {
const getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const { unmount } = render(
<PromptTestModal
prompt={makePrompt({ arguments: undefined, description: undefined })}
inspectorClient={inspectorClient}
width={120}
height={30}
onClose={() => {}}
/>,
);
await tick();
unmount();
await tick();
expect(true).toBe(true);
});
});

View file

@ -0,0 +1,291 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
// 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 { PromptsTab } from "../src/components/PromptsTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting. The longer delay also lets the async
// getPrompt IIFE + setState settle.
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));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const makePrompt = (over: Partial<Prompt> = {}): Prompt =>
({
name: "alpha",
description: "First prompt",
...over,
}) as unknown as Prompt;
// p0: full prompt with multi-line description + three argument variants
// (description present / `type` fallback / "string" fallback)
// p1: prompt with no description and no arguments (absent branches)
// p2: empty name → "Prompt N" fallback label + index key
// p3: trailing prompt for scroll-window coverage
const prompts: Prompt[] = [
makePrompt({
name: "alpha",
description: "Line one\nLine two",
arguments: [
{ name: "withDesc", description: "the description" },
{ name: "withType", type: "number" } as never,
{ name: "bare" },
],
}),
makePrompt({ name: "beta", description: undefined, arguments: undefined }),
makePrompt({ name: "", description: "gamma desc" }),
makePrompt({ name: "delta", description: "Delta desc" }),
];
describe("PromptsTab", () => {
it("renders empty state when there are no prompts", () => {
const { lastFrame } = render(
<PromptsTab
prompts={[]}
inspectorClient={null}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Prompts (0)");
expect(frame).toContain("No prompts available");
expect(frame).toContain("Select a prompt to view details");
});
it("renders a populated list with the first prompt selected (unfocused)", () => {
const { lastFrame } = render(
<PromptsTab
prompts={prompts}
inspectorClient={null}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Prompts (4)");
expect(frame).toContain("alpha");
expect(frame).toContain("beta");
// empty-name prompt falls back to "Prompt N"
expect(frame).toContain("Prompt 3");
expect(frame).toContain("▶ ");
// selected prompt details (cyan branch since details not focused)
expect(frame).toContain("Line one");
expect(frame).toContain("Line two");
// arguments + their three description fallbacks
expect(frame).toContain("Arguments:");
expect(frame).toContain("the description");
expect(frame).toContain("number");
expect(frame).toContain("bare: string");
expect(frame).toContain("[Enter to Get Prompt]");
});
it("moves selection down/up with arrow keys when the list is focused", async () => {
const { lastFrame, stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={null}
width={120}
height={30}
focusedPane="list"
/>,
);
// up at the top boundary: no movement
stdin.write(UP);
await tick();
// down moves selection to "beta" (no description, no arguments)
stdin.write(DOWN);
await tick();
let frame = lastFrame() ?? "";
expect(frame).toContain("beta");
expect(frame).not.toContain("Arguments:");
// back up to alpha
stdin.write(UP);
await tick();
frame = lastFrame() ?? "";
expect(frame).toContain("Line one");
});
it("scrolls the visible window when navigating past the viewport", async () => {
// height 9 → visibleCount = 2; 4 prompts force firstVisible to advance
const { lastFrame, stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={null}
width={120}
height={9}
focusedPane="list"
/>,
);
// Overshoot: the downArrow guard clamps at the last index, so extra
// presses are harmless and absorb any dropped first keypress.
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("delta");
// down boundary: pressing down again does nothing
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("delta");
});
it("calls onFetchPrompt when Enter is pressed on a prompt with arguments", async () => {
const onFetchPrompt = vi.fn();
const inspectorClient = {
getPrompt: vi.fn(),
} as unknown as InspectorClient;
const { stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={onFetchPrompt}
/>,
);
stdin.write("\r");
await tick();
expect(onFetchPrompt).toHaveBeenCalledWith(prompts[0]);
});
it("fetches directly and calls onViewDetails when Enter is pressed on an argument-less prompt", async () => {
const onFetchPrompt = vi.fn();
const onViewDetails = vi.fn();
const result = { messages: [] };
const getPrompt = vi.fn().mockResolvedValue({ result });
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const noArgPrompt = makePrompt({ name: "solo", arguments: undefined });
const { stdin } = render(
<PromptsTab
prompts={[noArgPrompt]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={onFetchPrompt}
onViewDetails={onViewDetails}
/>,
);
stdin.write("\r");
await tick();
expect(getPrompt).toHaveBeenCalledWith("solo");
expect(onFetchPrompt).not.toHaveBeenCalled();
expect(onViewDetails).toHaveBeenCalledWith(
expect.objectContaining({ name: "solo", result }),
);
});
it("renders the Error message when getPrompt rejects with an Error", async () => {
const getPrompt = vi.fn().mockRejectedValue(new Error("boom failure"));
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const noArgPrompt = makePrompt({ name: "solo", arguments: undefined });
const { lastFrame, stdin } = render(
<PromptsTab
prompts={[noArgPrompt]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={vi.fn()}
onViewDetails={vi.fn()}
/>,
);
stdin.write("\r");
await tick();
expect(lastFrame() ?? "").toContain("boom failure");
});
it("falls back to a generic Error message when getPrompt rejects with a non-Error", async () => {
const getPrompt = vi.fn().mockRejectedValue("oops");
const inspectorClient = { getPrompt } as unknown as InspectorClient;
const noArgPrompt = makePrompt({ name: "solo", arguments: undefined });
const { lastFrame, stdin } = render(
<PromptsTab
prompts={[noArgPrompt]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={vi.fn()}
onViewDetails={vi.fn()}
/>,
);
stdin.write("\r");
await tick();
expect(lastFrame() ?? "").toContain("Failed to get prompt");
});
it("handles details-pane scrolling, footer, and zoom shortcut", async () => {
const onViewDetails = vi.fn();
const { lastFrame, stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={null}
width={120}
height={30}
focusedPane="details"
onViewDetails={onViewDetails}
/>,
);
// footer only shows while the details pane is focused
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
// scroll keys (exercise scrollBy / pageUp / pageDown branches)
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
// "+" opens the full-screen modal
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith(prompts[0]);
});
it("does not fire input handlers when a modal is open", async () => {
const onFetchPrompt = vi.fn();
const inspectorClient = {
getPrompt: vi.fn(),
} as unknown as InspectorClient;
const { stdin } = render(
<PromptsTab
prompts={prompts}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchPrompt={onFetchPrompt}
modalOpen={true}
/>,
);
stdin.write("\r");
await tick();
expect(onFetchPrompt).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,364 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { FetchRequestEntry } from "@inspector/core/mcp/index.js";
// 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 { RequestsTab } from "../src/components/RequestsTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write.
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));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const TS = new Date("2024-01-01T12:34:56Z");
const req = (over: Partial<FetchRequestEntry>): FetchRequestEntry => ({
id: "id",
timestamp: TS,
method: "POST",
url: "https://example.com/mcp",
requestHeaders: { "content-type": "application/json" },
category: "transport",
...over,
});
// A fully-populated, successful transport request (2xx, JSON bodies).
const fullRequest = req({
id: "r0",
category: "auth",
method: "GET",
url: "https://example.com/oauth",
responseStatus: 200,
responseStatusText: "OK",
duration: 12,
requestHeaders: { authorization: "Bearer x" },
requestBody: JSON.stringify({ grant_type: "code" }),
responseHeaders: { "content-type": "application/json" },
responseBody: JSON.stringify({ access_token: "tok" }),
});
const errorRequest = req({
id: "r1",
method: "POST",
error: "connection refused",
});
const pendingRequest = req({
id: "r2",
method: "DELETE",
});
const nonJsonRequest = req({
id: "r3",
method: "PUT",
responseStatus: 404,
responseStatusText: "Not Found",
requestBody: "this is not json",
responseHeaders: {},
responseBody: "neither is this",
});
const redirectRequest = req({
id: "r4",
method: "GET",
responseStatus: 301,
});
const informationalRequest = req({
id: "r5",
method: "GET",
responseStatus: 100,
});
describe("RequestsTab", () => {
it("renders the empty state and reports a count of 0", () => {
const onCountChange = vi.fn();
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[]}
width={120}
height={30}
onCountChange={onCountChange}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Requests (0)");
expect(frame).toContain("No requests");
expect(frame).toContain("Select a request to view details");
expect(onCountChange).toHaveBeenCalledWith(0);
});
it("works without an onCountChange callback", () => {
const { lastFrame } = render(
<RequestsTab serverName="srv" requests={[]} width={120} height={30} />,
);
expect(lastFrame() ?? "").toContain("Requests (0)");
});
it("renders the list with status colors, labels and durations", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[
fullRequest,
errorRequest,
pendingRequest,
informationalRequest,
]}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Requests (4)");
// category labels
expect(frame).toContain("AUTH");
expect(frame).toContain("MCP");
// GET is padded; non-GET methods shown as-is
expect(frame).toContain("GET");
expect(frame).toContain("POST");
// status text variants: numeric / ERROR / "..."
expect(frame).toContain("200");
expect(frame).toContain("ERROR");
expect(frame).toContain("...");
// duration suffix
expect(frame).toContain("12ms");
// selection marker
expect(frame).toContain("▶ ");
});
it("renders full details for a successful request with JSON bodies", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("GET https://example.com/oauth");
expect(frame).toContain("Category:");
expect(frame).toContain("auth");
expect(frame).toContain("Status:");
expect(frame).toContain("200 OK");
expect(frame).toContain("(12ms)");
expect(frame).toContain("Request Headers:");
expect(frame).toContain("authorization: Bearer x");
expect(frame).toContain("Request Body:");
expect(frame).toContain("grant_type");
expect(frame).toContain("Response Headers:");
expect(frame).toContain("Response Body:");
expect(frame).toContain("access_token");
});
it("renders an error request detail", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[errorRequest]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("transport");
expect(frame).toContain("Error: connection refused");
});
it("renders the in-progress placeholder when there is no status or error", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[pendingRequest]}
width={120}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("Request in progress...");
});
it("renders raw (non-JSON) request and response bodies", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[nonJsonRequest]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("404 Not Found");
expect(frame).toContain("this is not json");
expect(frame).toContain("neither is this");
// empty responseHeaders object → section omitted
expect(frame).not.toContain("Response Headers:");
});
it("renders a redirect (3xx) status detail", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[redirectRequest]}
width={120}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("301");
});
it("highlights the details header when the details pane is focused", () => {
const { lastFrame } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
focusedPane="details"
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("↑/↓ to scroll, + to zoom");
expect(frame).toContain("GET https://example.com/oauth");
});
it("moves selection with arrows and page keys when the list is focused", async () => {
const many: FetchRequestEntry[] = Array.from({ length: 8 }, (_, i) =>
req({
id: `m${i}`,
method: i % 2 === 0 ? "GET" : "POST",
url: `https://example.com/r${i}`,
responseStatus: 200,
}),
);
const { lastFrame, stdin } = render(
<RequestsTab
serverName="srv"
requests={many}
width={120}
height={12}
focusedPane="requests"
/>,
);
// up at top boundary: no movement
stdin.write(UP);
await tick();
// down moves selection to the next request
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("https://example.com/r1");
// pageDown jumps toward the end
stdin.write(PAGE_DOWN);
await tick();
expect(lastFrame() ?? "").toContain("https://example.com/r");
// pageUp back toward the start
stdin.write(PAGE_UP);
await tick();
// up moves back toward the top
stdin.write(UP);
await tick();
expect(lastFrame() ?? "").toContain("Requests (8)");
});
it("clamps at the bottom when paging past the end", async () => {
const many: FetchRequestEntry[] = Array.from({ length: 4 }, (_, i) =>
req({ id: `c${i}`, url: `https://example.com/c${i}` }),
);
const { lastFrame, stdin } = render(
<RequestsTab
serverName="srv"
requests={many}
width={120}
height={10}
focusedPane="requests"
/>,
);
// overshoot down to the last index, then page down past the end
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("https://example.com/c3");
});
it("handles details-pane scrolling and the zoom shortcut", async () => {
const onViewDetails = vi.fn();
const { lastFrame, stdin } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
/>,
);
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith(fullRequest);
});
it("does not fire input handlers when a modal is open", async () => {
const onViewDetails = vi.fn();
const { stdin } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
modalOpen={true}
/>,
);
stdin.write("+");
await tick();
expect(onViewDetails).not.toHaveBeenCalled();
});
it("ignores '+' when no onViewDetails handler is provided", async () => {
const { lastFrame, stdin } = render(
<RequestsTab
serverName="srv"
requests={[fullRequest]}
width={120}
height={40}
focusedPane="details"
/>,
);
stdin.write("+");
await tick();
// still rendered, no crash
expect(lastFrame() ?? "").toContain("GET https://example.com/oauth");
});
});

View file

@ -0,0 +1,267 @@
import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
// ScrollView passthrough so the results JSX actually mounts (and is covered).
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
// Form double that fires onSubmit when the user presses Enter ("\r").
vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
import { ResourceTestModal } from "../src/components/ResourceTestModal.js";
// These modals render position="absolute", which produces an EMPTY frame under
// ink-testing-library. So we assert on BEHAVIOR — the injected client fake's
// readResourceFromTemplate, onClose, and the state transitions they drive —
// rather than on lastFrame(). React still EXECUTES the inner results/error JSX,
// so its coverage is collected even though it isn't visible.
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));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
type Template = {
name: string;
uriTemplate: string;
description?: string;
};
const makeTemplate = (over: Partial<Template> = {}): Template => ({
name: "Greeting",
uriTemplate: "greeting://{name}",
...over,
});
const fakeClient = (readResourceFromTemplate: unknown): InspectorClient =>
({ readResourceFromTemplate }) as unknown as InspectorClient;
const setSubmitValue = (value: Record<string, unknown>) => {
(globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__ = value;
};
afterEach(() => {
delete (globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__;
vi.restoreAllMocks();
});
const renderAndSubmit = async (
client: InspectorClient | null,
template: Template = makeTemplate(),
submitValue: Record<string, unknown> = { name: "world" },
) => {
const onClose = vi.fn();
const api = render(
<ResourceTestModal
template={template}
inspectorClient={client}
width={80}
height={24}
onClose={onClose}
/>,
);
await tick();
setSubmitValue(submitValue);
api.stdin.write("\r");
await tick();
await tick();
return { ...api, onClose };
};
describe("ResourceTestModal", () => {
it("renders the form initially without invoking the client", async () => {
const read = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
expect(read).not.toHaveBeenCalled();
api.unmount();
});
it("renders the template description when present", async () => {
const read = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate({ description: "A friendly greeting" })}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
api.unmount();
});
it("uses the 'Unknown Template' label when the template has an empty name", async () => {
const read = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate({ name: "" })}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
api.unmount();
});
it("renders the loading state while the read is in flight", async () => {
let resolveRead: (v: unknown) => void = () => {};
const pending = new Promise((resolve) => {
resolveRead = resolve;
});
const read = vi.fn().mockReturnValue(pending);
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
setSubmitValue({ name: "world" });
api.stdin.write("\r");
await tick();
// The component is now committed in the "loading" state (read not resolved).
expect(read).toHaveBeenCalled();
resolveRead({
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
expandedUri: "greeting://world",
});
await tick();
await tick();
api.unmount();
});
it("reads the resource and shows successful content", async () => {
const read = vi.fn().mockResolvedValue({
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
expandedUri: "greeting://world",
});
const { onClose, stdin, unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate(),
{ name: "world" },
);
expect(read).toHaveBeenCalledWith("greeting://{name}", { name: "world" });
// Drive scroll keys in results state for scrollBy / page coverage.
stdin.write(DOWN);
await tick();
stdin.write(UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("catches an Error thrown by readResourceFromTemplate", async () => {
const read = vi.fn().mockRejectedValue(new Error("not found"));
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("catches a string thrown by readResourceFromTemplate", async () => {
const read = vi.fn().mockRejectedValue("boom");
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("catches an object error carrying uri and message (uri + Object.assign branches)", async () => {
const read = vi
.fn()
.mockRejectedValue({ message: "bad uri", uri: "greeting://expanded" });
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("catches an object error without message or uri (Object.assign + Unknown error)", async () => {
const read = vi.fn().mockRejectedValue({ code: 42 });
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("catches a non-object, non-string throw (else branch)", async () => {
const read = vi.fn().mockRejectedValue(12345);
const { unmount } = await renderAndSubmit(fakeClient(read));
expect(read).toHaveBeenCalled();
unmount();
});
it("does nothing on submit when inspectorClient is null (early-return guard)", async () => {
const { onClose, unmount } = await renderAndSubmit(null);
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("closes on ESC while in form state", async () => {
const onClose = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(vi.fn())}
width={80}
height={24}
onClose={onClose}
/>,
);
await tick();
api.stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
api.unmount();
});
it("closes on ESC while in results state", async () => {
const read = vi.fn().mockResolvedValue({
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
expandedUri: "greeting://world",
});
const { onClose, stdin, unmount } = await renderAndSubmit(fakeClient(read));
stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
it("responds to a stdout resize event", async () => {
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(vi.fn())}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
process.stdout.emit("resize");
await tick();
api.unmount();
});
});

View file

@ -0,0 +1,400 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
// 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 { ResourcesTab } from "../src/components/ResourcesTab.js";
interface ResourceTemplate {
name: string;
uriTemplate: string;
description?: string;
}
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting. The longer delay also lets the async
// readResource effect + setState settle.
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));
};
/** Poll the frame until it contains `substr` — stable under coverage load. */
async function waitForFrame(
getFrame: () => string | undefined,
substr: string,
tries = 25,
) {
for (let i = 0; i < tries; i++) {
if ((getFrame() ?? "").includes(substr)) return;
await tick();
}
}
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const makeResource = (over: Partial<Resource> = {}): Resource =>
({
name: "res-alpha",
uri: "file:///a",
...over,
}) as unknown as Resource;
// r0: full resource (multi-line description, uri, mimeType)
// r1: no name → header/label fall back to uri
// r2: no name and no uri → "Resource N" label fallback + index key + falsy uri
const resources: Resource[] = [
makeResource({
name: "res-alpha",
uri: "file:///a",
mimeType: "text/plain",
description: "Desc one\nDesc two",
}),
makeResource({ name: undefined, uri: "file:///b" }),
makeResource({ name: undefined, uri: undefined }),
];
// t0: full template (description + uriTemplate)
// t1: no name and no uriTemplate → "Template N" label + index key, no uri line
const templates: ResourceTemplate[] = [
{
name: "tmpl-alpha",
uriTemplate: "file:///{id}",
description: "Template desc",
},
{ name: "", uriTemplate: "" },
];
describe("ResourcesTab", () => {
it("renders empty state when there are no resources or templates", () => {
const { lastFrame } = render(
<ResourcesTab
resources={[]}
inspectorClient={null}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Resources (0)");
expect(frame).toContain("No resources available");
expect(frame).toContain("Select a resource or template to view details");
});
it("renders resources and templates with the first resource selected (unfocused)", () => {
const { lastFrame } = render(
<ResourcesTab
resources={resources}
resourceTemplates={templates}
inspectorClient={null}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Resources (5)");
expect(frame).toContain("res-alpha");
// r1 label falls back to uri
expect(frame).toContain("file:///b");
// r2 label falls back to "Resource N"
expect(frame).toContain("Resource 3");
expect(frame).toContain("tmpl-alpha");
// t1 label falls back to "Template N"
expect(frame).toContain("Template 2");
expect(frame).toContain("▶ ");
// first resource details (cyan branch)
expect(frame).toContain("Desc one");
expect(frame).toContain("Desc two");
expect(frame).toContain("URI: file:///a");
expect(frame).toContain("MIME Type: text/plain");
expect(frame).toContain("[Enter to Fetch Resource]");
});
it("shows the resource header uri fallback when the resource has no name", () => {
const { lastFrame } = render(
<ResourcesTab
resources={[makeResource({ name: undefined, uri: "file:///only" })]}
inspectorClient={null}
width={120}
height={30}
/>,
);
expect(lastFrame() ?? "").toContain("file:///only");
});
it("renders template details when a template is selected", () => {
const { lastFrame } = render(
<ResourcesTab
resources={[]}
resourceTemplates={[templates[0]]}
inspectorClient={null}
width={120}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("tmpl-alpha");
expect(frame).toContain("Template desc");
expect(frame).toContain("URI Template: file:///{id}");
expect(frame).toContain("[Enter to Fetch Resource]");
});
it("moves selection down/up with arrow keys when the list is focused", async () => {
const { lastFrame, stdin } = render(
<ResourcesTab
resources={resources}
resourceTemplates={templates}
inspectorClient={null}
width={120}
height={30}
focusedPane="list"
/>,
);
// up at top boundary: no movement
stdin.write(UP);
await tick();
// down to r1 (no name, uri only)
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("URI: file:///b");
// back up to r0
stdin.write(UP);
await tick();
expect(lastFrame() ?? "").toContain("Desc one");
});
it("scrolls the visible window when navigating past the viewport", async () => {
// height 9 → visibleCount = 2; 5 items force firstVisible to advance
const { lastFrame, stdin } = render(
<ResourcesTab
resources={resources}
resourceTemplates={templates}
inspectorClient={null}
width={120}
height={9}
focusedPane="list"
/>,
);
for (let i = 0; i < 6; i++) {
stdin.write(DOWN);
await tick();
}
// last item is the second template ("Template 2")
expect(lastFrame() ?? "").toContain("Template 2");
// down boundary: pressing down again does nothing
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("Template 2");
});
it("fetches resource content on Enter and renders it", async () => {
const result = { contents: [{ uri: "file:///a", text: "hello world" }] };
const readResource = vi.fn().mockResolvedValue({ result });
const onFetchResource = vi.fn();
const inspectorClient = { readResource } as unknown as InspectorClient;
const { lastFrame, stdin } = render(
<ResourcesTab
resources={[resources[0]]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchResource={onFetchResource}
/>,
);
stdin.write("\r");
await tick();
await tick();
expect(readResource).toHaveBeenCalledWith("file:///a");
expect(onFetchResource).toHaveBeenCalledWith(resources[0]);
const frame = lastFrame() ?? "";
expect(frame).toContain("Content:");
expect(frame).toContain("hello world");
});
it("renders the Error message when readResource rejects with an Error", async () => {
const readResource = vi.fn().mockRejectedValue(new Error("read boom"));
const inspectorClient = { readResource } as unknown as InspectorClient;
const { lastFrame, stdin } = render(
<ResourcesTab
resources={[resources[0]]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchResource={vi.fn()}
/>,
);
stdin.write("\r");
await tick();
await tick();
expect(lastFrame() ?? "").toContain("read boom");
});
it("falls back to a generic Error message when readResource rejects with a non-Error", async () => {
const readResource = vi.fn().mockRejectedValue("nope");
const inspectorClient = { readResource } as unknown as InspectorClient;
const { lastFrame, stdin } = render(
<ResourcesTab
resources={[resources[0]]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchResource={vi.fn()}
/>,
);
stdin.write("\r");
await tick();
await tick();
expect(lastFrame() ?? "").toContain("Failed to read resource");
});
it("calls onFetchTemplate when Enter is pressed on a template", async () => {
const onFetchTemplate = vi.fn();
const inspectorClient = {
readResource: vi.fn(),
} as unknown as InspectorClient;
const { stdin } = render(
<ResourcesTab
resources={[]}
resourceTemplates={[templates[0]]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchTemplate={onFetchTemplate}
/>,
);
stdin.write("\r");
await tick();
expect(onFetchTemplate).toHaveBeenCalledWith(templates[0]);
});
it("handles details-pane scrolling, footers, and zoom shortcut after fetch", async () => {
const result = { contents: [{ uri: "file:///a", text: "zoom me" }] };
const readResource = vi.fn().mockResolvedValue({ result });
const onViewDetails = vi.fn();
const inspectorClient = { readResource } as unknown as InspectorClient;
const { lastFrame, stdin } = render(
<ResourcesTab
resources={[resources[0]]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="details"
onViewDetails={onViewDetails}
onFetchResource={vi.fn()}
/>,
);
// no content yet → "Enter to fetch" footer variant
expect(lastFrame() ?? "").toContain("Enter to fetch, ↑/↓ to scroll");
// scroll keys (scrollBy / pageUp / pageDown branches)
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
// fetch content via Enter (works from the details pane too)
stdin.write("\r");
await tick();
await tick();
// footer switches to the zoom variant
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
// "+" opens the full-screen modal with the fetched content
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith({ content: result });
});
it("shows the template fetch footer when a template is focused in details", () => {
const { lastFrame } = render(
<ResourcesTab
resources={[]}
resourceTemplates={[templates[0]]}
inspectorClient={null}
width={120}
height={30}
focusedPane="details"
/>,
);
expect(lastFrame() ?? "").toContain("Enter to fetch");
});
it("does not fire input handlers when a modal is open", async () => {
const onFetchResource = vi.fn();
const inspectorClient = {
readResource: vi.fn(),
} as unknown as InspectorClient;
const { stdin } = render(
<ResourcesTab
resources={[resources[0]]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onFetchResource={onFetchResource}
modalOpen={true}
/>,
);
stdin.write("\r");
await tick();
expect(onFetchResource).not.toHaveBeenCalled();
});
it("invokes onCountChange and clears fetched content when the resources list changes", async () => {
const result = { contents: [{ uri: "file:///a", text: "stale" }] };
const readResource = vi.fn().mockResolvedValue({ result });
const onCountChange = vi.fn();
const inspectorClient = { readResource } as unknown as InspectorClient;
const { lastFrame, stdin, rerender } = render(
<ResourcesTab
resources={[resources[0]]}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onCountChange={onCountChange}
onFetchResource={vi.fn()}
/>,
);
// fetch content for the first resource
stdin.write("\r");
await waitForFrame(lastFrame, "stale");
expect(lastFrame() ?? "").toContain("stale");
// swapping in a new resources array clears content and updates the count
const nextResources = [resources[0], resources[1]];
rerender(
<ResourcesTab
resources={nextResources}
inspectorClient={inspectorClient}
width={120}
height={30}
focusedPane="list"
onCountChange={onCountChange}
onFetchResource={vi.fn()}
/>,
);
await tick();
expect(onCountChange).toHaveBeenCalledWith(2);
const frame = lastFrame() ?? "";
expect(frame).not.toContain("stale");
expect(frame).toContain("[Enter to Fetch Resource]");
});
});

View file

@ -0,0 +1,36 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render } from "ink-testing-library";
import { SelectableItem } from "../src/components/SelectableItem.js";
describe("SelectableItem", () => {
it("shows the ▶ marker and label when selected", () => {
const { lastFrame } = render(
<SelectableItem isSelected>Tool A</SelectableItem>,
);
expect(lastFrame()).toContain("▶");
expect(lastFrame()).toContain("Tool A");
});
it("omits the marker when not selected", () => {
const { lastFrame } = render(
<SelectableItem isSelected={false}>Tool B</SelectableItem>,
);
expect(lastFrame()).not.toContain("▶");
expect(lastFrame()).toContain("Tool B");
});
it("renders bold and non-bold variants without error", () => {
const bold = render(
<SelectableItem isSelected bold>
Bold
</SelectableItem>,
);
expect(bold.lastFrame()).toContain("Bold");
const plain = render(
<SelectableItem isSelected={false}>Plain</SelectableItem>,
);
expect(plain.lastFrame()).toContain("Plain");
});
});

View file

@ -0,0 +1,81 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render } from "ink-testing-library";
import { Tabs } from "../src/components/Tabs.js";
const noop = () => {};
describe("Tabs", () => {
it("renders the default visible tabs (auth + logging shown, requests hidden)", () => {
const { lastFrame } = render(
<Tabs activeTab="info" onTabChange={noop} width={120} />,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Info");
expect(frame).toContain("Auth");
expect(frame).toContain("Logging");
// requests defaults to hidden
expect(frame).not.toContain("HTTP Requests");
});
it("hides the auth tab when showAuth is false", () => {
const { lastFrame } = render(
<Tabs activeTab="info" onTabChange={noop} width={120} showAuth={false} />,
);
expect(lastFrame() ?? "").not.toContain("Auth");
});
it("hides the logging tab when showLogging is false", () => {
const { lastFrame } = render(
<Tabs
activeTab="info"
onTabChange={noop}
width={120}
showLogging={false}
/>,
);
expect(lastFrame() ?? "").not.toContain("Logging");
});
it("shows the requests tab when showRequests is true", () => {
const { lastFrame } = render(
<Tabs
activeTab="info"
onTabChange={noop}
width={120}
showRequests={true}
/>,
);
expect(lastFrame() ?? "").toContain("HTTP Requests");
});
it("marks the active tab with the ▶ marker", () => {
const { lastFrame } = render(
<Tabs activeTab="tools" onTabChange={noop} width={120} />,
);
expect(lastFrame() ?? "").toContain("▶");
});
it("renders counts when provided and omits them otherwise", () => {
const withCounts = render(
<Tabs
activeTab="info"
onTabChange={noop}
width={120}
counts={{ tools: 3, resources: 0 }}
/>,
);
const frame = withCounts.lastFrame() ?? "";
expect(frame).toContain("(3)");
// count of 0 is defined, so it still renders
expect(frame).toContain("(0)");
});
it("highlights the focused active tab", () => {
const { lastFrame } = render(
<Tabs activeTab="info" onTabChange={noop} width={120} focused={true} />,
);
// focused active tab path renders without error and shows the marker
expect(lastFrame() ?? "").toContain("▶");
});
});

View file

@ -0,0 +1,280 @@
import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
// ScrollView passthrough so the results JSX actually mounts (and is covered).
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
// Form double that fires onSubmit when the user presses Enter ("\r").
vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
import { ToolTestModal } from "../src/components/ToolTestModal.js";
// These modals render position="absolute", which produces an EMPTY frame under
// ink-testing-library (absolute boxes aren't laid out at the root). So we assert
// on BEHAVIOR — the injected client fake's methods, onClose, and the state
// transitions they drive — rather than on lastFrame() content. React still
// EXECUTES the inner results/error/loading JSX, so its coverage is collected.
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));
};
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
const makeTool = (over: Partial<Tool> = {}): Tool =>
({
name: "alpha",
description: "First tool",
inputSchema: { type: "object", properties: {} },
...over,
}) as unknown as Tool;
const fakeClient = (callTool: unknown): InspectorClient =>
({ callTool }) as unknown as InspectorClient;
// Set the value the Form double submits on Enter; cleared after each test.
const setSubmitValue = (value: Record<string, unknown>) => {
(globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__ = value;
};
afterEach(() => {
delete (globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__;
vi.restoreAllMocks();
});
// Render → submit form → let the awaited callTool + setState settle.
const renderAndSubmit = async (
client: InspectorClient | null,
submitValue: Record<string, unknown> = {},
) => {
const onClose = vi.fn();
const api = render(
<ToolTestModal
tool={makeTool()}
inspectorClient={client}
width={80}
height={24}
onClose={onClose}
/>,
);
await tick();
setSubmitValue(submitValue);
api.stdin.write("\r");
await tick();
await tick();
return { ...api, onClose };
};
describe("ToolTestModal", () => {
it("renders the form initially without invoking the client", async () => {
const callTool = vi.fn();
const api = render(
<ToolTestModal
tool={makeTool()}
inspectorClient={fakeClient(callTool)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
expect(callTool).not.toHaveBeenCalled();
api.unmount();
});
it("falls back to a default form structure (and name) when the tool has no inputSchema or name", async () => {
const callTool = vi.fn();
const tool = makeTool({ inputSchema: undefined, name: "" });
const api = render(
<ToolTestModal
tool={tool}
inspectorClient={fakeClient(callTool)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
api.unmount();
});
it("uses the 'Unknown Tool' label when a schema-bearing tool has an empty name", async () => {
const callTool = vi.fn();
const tool = makeTool({ name: "" });
const api = render(
<ToolTestModal
tool={tool}
inspectorClient={fakeClient(callTool)}
width={80}
height={24}
onClose={vi.fn()}
/>,
);
await tick();
api.unmount();
});
it("renders the loading state while the call is in flight", async () => {
let resolveCall: (v: unknown) => void = () => {};
const pending = new Promise((resolve) => {
resolveCall = resolve;
});
const callTool = vi.fn().mockReturnValue(pending);
const onClose = vi.fn();
const api = render(
<ToolTestModal
tool={makeTool()}
inspectorClient={fakeClient(callTool)}
width={80}
height={24}
onClose={onClose}
/>,
);
await tick();
setSubmitValue({});
api.stdin.write("\r");
await tick();
// The component is now committed in the "loading" state (call not resolved).
expect(callTool).toHaveBeenCalled();
resolveCall({
success: true,
result: { content: [{ type: "text", text: "done" }] },
});
await tick();
await tick();
api.unmount();
});
it("calls callTool and shows successful output", async () => {
const callTool = vi.fn().mockResolvedValue({
success: true,
result: { content: [{ type: "text", text: "hello" }] },
error: undefined,
});
const { onClose, stdin, unmount } = await renderAndSubmit(
fakeClient(callTool),
{ foo: "bar" },
);
expect(callTool).toHaveBeenCalledWith(makeTool(), { foo: "bar" });
// Drive scroll keys in results state for scrollBy / page coverage.
stdin.write(DOWN);
await tick();
stdin.write(UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("renders the error branch when the result has isError === true", async () => {
const callTool = vi.fn().mockResolvedValue({
success: true,
result: { isError: true, content: [{ type: "text", text: "oops" }] },
});
const { unmount } = await renderAndSubmit(fakeClient(callTool));
expect(callTool).toHaveBeenCalled();
unmount();
});
it("renders the failed-call branch when success is false and result is null", async () => {
const callTool = vi.fn().mockResolvedValue({
success: false,
result: null,
error: "tool blew up",
});
const { unmount } = await renderAndSubmit(fakeClient(callTool));
expect(callTool).toHaveBeenCalled();
unmount();
});
it("uses the default error message when a failed call has no error string", async () => {
const callTool = vi.fn().mockResolvedValue({
success: false,
result: null,
});
const { unmount } = await renderAndSubmit(fakeClient(callTool));
expect(callTool).toHaveBeenCalled();
unmount();
});
it("catches an Error thrown by callTool", async () => {
const callTool = vi.fn().mockRejectedValue(new Error("network down"));
const { unmount } = await renderAndSubmit(fakeClient(callTool));
expect(callTool).toHaveBeenCalled();
unmount();
});
it("catches a non-Error value thrown by callTool", async () => {
const callTool = vi.fn().mockRejectedValue("boom");
const { unmount } = await renderAndSubmit(fakeClient(callTool));
expect(callTool).toHaveBeenCalled();
unmount();
});
it("does nothing on submit when inspectorClient is null (early-return guard)", async () => {
const { onClose, unmount } = await renderAndSubmit(null);
// No client to call; stays in form state and onClose untouched.
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("closes on ESC while in form state", async () => {
const onClose = vi.fn();
const api = render(
<ToolTestModal
tool={makeTool()}
inspectorClient={fakeClient(vi.fn())}
width={80}
height={24}
onClose={onClose}
/>,
);
await tick();
api.stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
api.unmount();
});
it("closes on ESC while in results state", async () => {
const callTool = vi.fn().mockResolvedValue({
success: true,
result: { content: [{ type: "text", text: "hi" }] },
});
const { onClose, stdin, unmount } = await renderAndSubmit(
fakeClient(callTool),
);
stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
it("responds to a stdout resize event", async () => {
const onClose = vi.fn();
const api = render(
<ToolTestModal
tool={makeTool()}
inspectorClient={fakeClient(vi.fn())}
width={80}
height={24}
onClose={onClose}
/>,
);
await tick();
process.stdout.emit("resize");
await tick();
api.unmount();
});
});

View file

@ -0,0 +1,208 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
// 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 { ToolsTab } from "../src/components/ToolsTab.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 so ink 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 makeTool = (over: Partial<Tool> = {}): Tool =>
({
name: "alpha",
description: "First tool",
inputSchema: { type: "object", properties: {} },
...over,
}) as unknown as Tool;
// t0: full tool with multi-line description + input schema
// t1: tool with no description and no input schema (absent branches)
// t2: tool with empty name (fallback label + index key)
// t3: trailing tool used for scroll-down coverage
const tools: Tool[] = [
makeTool({ name: "alpha", description: "Line one\nLine two" }),
makeTool({
name: "beta",
description: undefined,
inputSchema: undefined,
} as unknown as Tool),
makeTool({ name: "", description: "gamma desc" }),
makeTool({ name: "delta", description: "Delta desc" }),
];
describe("ToolsTab", () => {
it("renders empty state when there are no tools", () => {
const { lastFrame } = render(
<ToolsTab tools={[]} isConnected={false} width={120} height={30} />,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Tools (0)");
expect(frame).toContain("No tools available");
expect(frame).toContain("Select a tool to view details");
});
it("renders a populated list with the first tool selected (unfocused)", () => {
const { lastFrame } = render(
<ToolsTab tools={tools} isConnected={false} width={120} height={30} />,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Tools (4)");
expect(frame).toContain("alpha");
expect(frame).toContain("beta");
// empty-name tool falls back to "Tool N"
expect(frame).toContain("Tool 3");
expect(frame).toContain("▶ ");
// selected tool details (cyan branch since not focused on details)
expect(frame).toContain("Line one");
expect(frame).toContain("Line two");
expect(frame).toContain("Input Schema:");
// not connected → no Enter-to-Test affordance
expect(frame).not.toContain("[Enter to Test]");
});
it("shows the Enter-to-Test affordance when connected", () => {
const { lastFrame } = render(
<ToolsTab tools={tools} isConnected={true} width={120} height={30} />,
);
expect(lastFrame() ?? "").toContain("[Enter to Test]");
});
it("moves selection down/up with arrow keys when the list is focused", async () => {
const { lastFrame, stdin } = render(
<ToolsTab
tools={tools}
isConnected={false}
width={120}
height={30}
focusedPane="list"
/>,
);
// up at the top boundary: no movement
stdin.write(UP);
await tick();
// down moves selection to "beta" and renders its (empty) details
stdin.write(DOWN);
await tick();
let frame = lastFrame() ?? "";
expect(frame).toContain("beta");
// beta has no description and no input schema
expect(frame).not.toContain("Input Schema:");
// back up to alpha
stdin.write(UP);
await tick();
frame = lastFrame() ?? "";
expect(frame).toContain("Line one");
});
it("scrolls the visible window when navigating past the viewport", async () => {
// height 9 → visibleCount = 2; 4 tools forces firstVisible to advance
const { lastFrame, stdin } = render(
<ToolsTab
tools={tools}
isConnected={false}
width={120}
height={9}
focusedPane="list"
/>,
);
// Overshoot: the downArrow guard clamps at the last index, so extra
// presses are harmless and absorb any dropped first keypress.
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(DOWN);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("delta");
// down boundary: pressing down again does nothing
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("delta");
});
it("calls onTestTool when Enter is pressed while focused and connected", async () => {
const onTestTool = vi.fn();
const { stdin } = render(
<ToolsTab
tools={tools}
isConnected={true}
width={120}
height={30}
focusedPane="list"
onTestTool={onTestTool}
/>,
);
stdin.write("\r");
await tick();
expect(onTestTool).toHaveBeenCalledWith(tools[0]);
});
it("does not fire input handlers when a modal is open", async () => {
const onTestTool = vi.fn();
const { stdin } = render(
<ToolsTab
tools={tools}
isConnected={true}
width={120}
height={30}
focusedPane="list"
onTestTool={onTestTool}
modalOpen={true}
/>,
);
stdin.write("\r");
await tick();
expect(onTestTool).not.toHaveBeenCalled();
});
it("handles details-pane scrolling, footer, and zoom shortcut", async () => {
const onViewDetails = vi.fn();
const { lastFrame, stdin } = render(
<ToolsTab
tools={tools}
isConnected={true}
width={120}
height={30}
focusedPane="details"
onViewDetails={onViewDetails}
/>,
);
// footer only shows while the details pane is focused
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
// scroll keys (exercise scrollBy / pageUp / pageDown branches)
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
// "+" opens the full-screen modal
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith(tools[0]);
});
});

View file

@ -0,0 +1,43 @@
import React from "react";
import { Box, Text, useInput } from "ink";
/**
* Test double for `ink-form`.
*
* The real Form runs an interactive multi-field terminal widget that is hard
* to drive field-by-field under ink-testing-library. The modals only care
* that `onSubmit` eventually fires with a values object; the success / error
* / throw outcome is decided by the `inspectorClient` fake the test injects,
* not by the field values. So this double renders a marker plus the form
* title and invokes `onSubmit` when the user presses Enter ("\r").
*
* The submitted value defaults to `{}`; pass a different payload from a test
* by setting `globalThis.__INK_FORM_SUBMIT_VALUE__` before pressing Enter. It
* is consumed (cleared) on submit, so it never leaks into a later submit.
*
* Usage in a test file:
* vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
*/
interface MockFormProps {
form?: { title?: string };
onSubmit?: (value: object) => void;
}
export function Form({ form, onSubmit }: MockFormProps) {
useInput((_input, key) => {
if (key.return) {
// Consume-once: read the override then clear it, so a value set by one
// test can never leak into a later submit (no cross-test coupling on the
// shared global, regardless of how the test files are organized).
const g = globalThis as Record<string, unknown>;
const value = g.__INK_FORM_SUBMIT_VALUE__ ?? {};
delete g.__INK_FORM_SUBMIT_VALUE__;
onSubmit?.(value as object);
}
});
return (
<Box flexDirection="column">
<Text>MOCK_FORM:{form?.title ?? ""}</Text>
</Box>
);
}

View file

@ -0,0 +1,34 @@
import React from "react";
import { Box } from "ink";
/**
* Test double for `ink-scroll-view`.
*
* The real ScrollView measures the TTY viewport and virtualizes its
* children; under ink-testing-library there is no real terminal, so it
* renders a placeholder minimap and never mounts its children — which both
* hides inner content from `lastFrame()` and skips the inner JSX for
* coverage. This passthrough renders children directly inside a Box and
* stubs the imperative ref API (scrollBy / scrollTo / getViewportHeight)
* that components call from their useInput handlers.
*
* Usage in a test file:
* vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
*/
export interface ScrollViewRef {
scrollBy: (delta: number) => void;
scrollTo: (offset: number) => void;
getViewportHeight: () => number;
}
export const ScrollView = React.forwardRef<
ScrollViewRef,
{ children?: React.ReactNode; height?: number }
>(function ScrollView({ children }, ref) {
React.useImperativeHandle(ref, () => ({
scrollBy: () => {},
scrollTo: () => {},
getViewportHeight: () => 10,
}));
return <Box flexDirection="column">{children}</Box>;
});

View file

@ -0,0 +1,12 @@
// Shared vitest setup for the TUI renderer tests.
//
// Each ink-testing-library mount of a component that listens for terminal
// resizes (App.tsx and the test modals) registers a `process.stdout` "resize"
// listener for its lifetime. Across a file's many mount/unmount cycles the
// transient count briefly exceeds Node's default warning threshold of 10,
// printing a misleading "MaxListenersExceededWarning" (the listeners are
// removed on unmount — there is no real leak). Raising the cap here, in a
// setupFile that every test file inherits, silences the noise everywhere
// rather than per-file.
process.stdout.setMaxListeners(100);
process.stdin.setMaxListeners(100);

View file

@ -0,0 +1,134 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render } from "ink-testing-library";
import { Text, useInput } from "ink";
import {
useSelectableList,
type UseSelectableListOptions,
} from "../src/hooks/useSelectableList.js";
/** Let ink flush an async stdin keypress / re-render 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));
};
/**
* Harness that surfaces the hook's state as rendered text and forwards
* keypresses to setSelection so the hook can be driven from a test.
* Pressing a digit (0-9) selects that index; "x" selects index 99.
*/
function Harness({
itemCount,
visibleCount,
options,
}: {
itemCount: number;
visibleCount: number;
options?: UseSelectableListOptions;
}) {
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
itemCount,
visibleCount,
options,
);
useInput((input) => {
if (input === "x") setSelection(99);
else if (/^[0-9]$/.test(input)) setSelection(Number(input));
});
return (
<Text>
sel={selectedIndex} first={firstVisible}
</Text>
);
}
describe("useSelectableList", () => {
it("starts at index 0 with firstVisible 0", () => {
const { lastFrame } = render(<Harness itemCount={10} visibleCount={5} />);
expect(lastFrame()).toBe("sel=0 first=0");
});
it("keeps firstVisible when selecting within the visible window", async () => {
const { lastFrame, stdin } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("3");
await tick();
expect(lastFrame()).toBe("sel=3 first=0");
});
it("scrolls firstVisible forward when selection passes the window end", async () => {
const { lastFrame, stdin } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("6");
await tick();
// selected >= first + visibleCount => first = 6 - 5 + 1 = 2
expect(lastFrame()).toBe("sel=6 first=2");
});
it("scrolls firstVisible backward when selection moves before the window", async () => {
const { lastFrame, stdin } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("8"); // first becomes 4
await tick();
expect(lastFrame()).toBe("sel=8 first=4");
stdin.write("2"); // selected < first => first = selected = 2
await tick();
expect(lastFrame()).toBe("sel=2 first=2");
});
it("resets selection to 0 when resetWhen changes", async () => {
const { lastFrame, stdin, rerender } = render(
<Harness itemCount={10} visibleCount={5} options={{ resetWhen: "a" }} />,
);
stdin.write("7");
await tick();
expect(lastFrame()).toBe("sel=7 first=3");
rerender(
<Harness itemCount={10} visibleCount={5} options={{ resetWhen: "b" }} />,
);
await tick();
expect(lastFrame()).toBe("sel=0 first=0");
});
it("does not reset when resetWhen is undefined", async () => {
const { lastFrame, stdin, rerender } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("4");
await tick();
rerender(<Harness itemCount={10} visibleCount={5} />);
await tick();
expect(lastFrame()).toBe("sel=4 first=0");
});
it("clamps selection when the list shrinks below the selected index", async () => {
const { lastFrame, stdin, rerender } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("8");
await tick();
expect(lastFrame()).toBe("sel=8 first=4");
rerender(<Harness itemCount={3} visibleCount={5} />);
await tick();
// itemCount > 0 && selected >= itemCount => newIndex = 2, first clamps to 2
expect(lastFrame()).toBe("sel=2 first=2");
});
it("does not clamp when the list is empty", async () => {
const { lastFrame, stdin, rerender } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("8");
await tick();
rerender(<Harness itemCount={0} visibleCount={5} />);
await tick();
// itemCount === 0 => no clamp, selection retained
expect(lastFrame()).toBe("sel=8 first=4");
});
});

View file

@ -33,6 +33,7 @@
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.4.0",
"ink-testing-library": "^4.0.0",
"prettier": "^3.8.1",
"tsup": "^8.5.0",
"tsx": "^4.21.0",
@ -4073,6 +4074,24 @@
"react": ">=18.0.0"
}
},
"node_modules/ink-testing-library": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz",
"integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/react": ">=18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/ink-text-input": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",

Some files were not shown because too many files have changed in this diff Show more