* 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-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.4.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
@ -4073,6 +4074,24 @@
|
|||
"react": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ink-testing-library": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz",
|
||||
"integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ink-text-input": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue