* 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:
parent
0b0bd40e5d
commit
f43fafc08b
22 changed files with 5252 additions and 28 deletions
1133
clients/tui/__tests__/App.test.tsx
Normal file
1133
clients/tui/__tests__/App.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
622
clients/tui/__tests__/AuthTab.test.tsx
Normal file
622
clients/tui/__tests__/AuthTab.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
clients/tui/__tests__/DetailsModal.test.tsx
Normal file
102
clients/tui/__tests__/DetailsModal.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
275
clients/tui/__tests__/HistoryTab.test.tsx
Normal file
275
clients/tui/__tests__/HistoryTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
401
clients/tui/__tests__/InfoTab.test.tsx
Normal file
401
clients/tui/__tests__/InfoTab.test.tsx
Normal 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("[A"); // up arrow
|
||||||
|
await tick();
|
||||||
|
stdin.write("[B"); // 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
184
clients/tui/__tests__/NotificationsTab.test.tsx
Normal file
184
clients/tui/__tests__/NotificationsTab.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
344
clients/tui/__tests__/PromptTestModal.test.tsx
Normal file
344
clients/tui/__tests__/PromptTestModal.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
291
clients/tui/__tests__/PromptsTab.test.tsx
Normal file
291
clients/tui/__tests__/PromptsTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
364
clients/tui/__tests__/RequestsTab.test.tsx
Normal file
364
clients/tui/__tests__/RequestsTab.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
267
clients/tui/__tests__/ResourceTestModal.test.tsx
Normal file
267
clients/tui/__tests__/ResourceTestModal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
400
clients/tui/__tests__/ResourcesTab.test.tsx
Normal file
400
clients/tui/__tests__/ResourcesTab.test.tsx
Normal 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]");
|
||||||
|
});
|
||||||
|
});
|
||||||
36
clients/tui/__tests__/SelectableItem.test.tsx
Normal file
36
clients/tui/__tests__/SelectableItem.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
81
clients/tui/__tests__/Tabs.test.tsx
Normal file
81
clients/tui/__tests__/Tabs.test.tsx
Normal 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("▶");
|
||||||
|
});
|
||||||
|
});
|
||||||
280
clients/tui/__tests__/ToolTestModal.test.tsx
Normal file
280
clients/tui/__tests__/ToolTestModal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
208
clients/tui/__tests__/ToolsTab.test.tsx
Normal file
208
clients/tui/__tests__/ToolsTab.test.tsx
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
clients/tui/__tests__/helpers/inkFormMock.tsx
Normal file
43
clients/tui/__tests__/helpers/inkFormMock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
clients/tui/__tests__/helpers/inkScrollViewMock.tsx
Normal file
34
clients/tui/__tests__/helpers/inkScrollViewMock.tsx
Normal 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>;
|
||||||
|
});
|
||||||
12
clients/tui/__tests__/setup.ts
Normal file
12
clients/tui/__tests__/setup.ts
Normal 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);
|
||||||
134
clients/tui/__tests__/useSelectableList.test.tsx
Normal file
134
clients/tui/__tests__/useSelectableList.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
19
clients/tui/package-lock.json
generated
19
clients/tui/package-lock.json
generated
|
|
@ -33,6 +33,7 @@
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
|
"ink-testing-library": "^4.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
|
@ -4073,6 +4074,24 @@
|
||||||
"react": ">=18.0.0"
|
"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": {
|
"node_modules/ink-text-input": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
"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
Loading…
Reference in a new issue