From 2aff4060566a833761c15cd83f8e02736234650a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 27 Jun 2026 17:34:59 -0400 Subject: [PATCH 1/9] 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) --- clients/tui/__tests__/SelectableItem.test.tsx | 36 +++++ clients/tui/__tests__/Tabs.test.tsx | 81 +++++++++++ .../tui/__tests__/useSelectableList.test.tsx | 129 ++++++++++++++++++ clients/tui/package-lock.json | 19 +++ clients/tui/package.json | 1 + clients/tui/vitest.config.ts | 48 ++++--- 6 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 clients/tui/__tests__/SelectableItem.test.tsx create mode 100644 clients/tui/__tests__/Tabs.test.tsx create mode 100644 clients/tui/__tests__/useSelectableList.test.tsx diff --git a/clients/tui/__tests__/SelectableItem.test.tsx b/clients/tui/__tests__/SelectableItem.test.tsx new file mode 100644 index 000000000..395d6a98c --- /dev/null +++ b/clients/tui/__tests__/SelectableItem.test.tsx @@ -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( + Tool A, + ); + expect(lastFrame()).toContain("▶"); + expect(lastFrame()).toContain("Tool A"); + }); + + it("omits the marker when not selected", () => { + const { lastFrame } = render( + Tool B, + ); + expect(lastFrame()).not.toContain("▶"); + expect(lastFrame()).toContain("Tool B"); + }); + + it("renders bold and non-bold variants without error", () => { + const bold = render( + + Bold + , + ); + expect(bold.lastFrame()).toContain("Bold"); + + const plain = render( + Plain, + ); + expect(plain.lastFrame()).toContain("Plain"); + }); +}); diff --git a/clients/tui/__tests__/Tabs.test.tsx b/clients/tui/__tests__/Tabs.test.tsx new file mode 100644 index 000000000..ce295c11c --- /dev/null +++ b/clients/tui/__tests__/Tabs.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(lastFrame() ?? "").not.toContain("Auth"); + }); + + it("hides the logging tab when showLogging is false", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").not.toContain("Logging"); + }); + + it("shows the requests tab when showRequests is true", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("HTTP Requests"); + }); + + it("marks the active tab with the ▶ marker", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("▶"); + }); + + it("renders counts when provided and omits them otherwise", () => { + const withCounts = render( + , + ); + 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( + , + ); + // focused active tab path renders without error and shows the marker + expect(lastFrame() ?? "").toContain("▶"); + }); +}); diff --git a/clients/tui/__tests__/useSelectableList.test.tsx b/clients/tui/__tests__/useSelectableList.test.tsx new file mode 100644 index 000000000..8a6d05611 --- /dev/null +++ b/clients/tui/__tests__/useSelectableList.test.tsx @@ -0,0 +1,129 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** + * 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 ( + + sel={selectedIndex} first={firstVisible} + + ); +} + +describe("useSelectableList", () => { + it("starts at index 0 with firstVisible 0", () => { + const { lastFrame } = render(); + expect(lastFrame()).toBe("sel=0 first=0"); + }); + + it("keeps firstVisible when selecting within the visible window", async () => { + const { lastFrame, stdin } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + stdin.write("7"); + await tick(); + expect(lastFrame()).toBe("sel=7 first=3"); + rerender( + , + ); + await tick(); + expect(lastFrame()).toBe("sel=0 first=0"); + }); + + it("does not reset when resetWhen is undefined", async () => { + const { lastFrame, stdin, rerender } = render( + , + ); + stdin.write("4"); + await tick(); + rerender(); + 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( + , + ); + stdin.write("8"); + await tick(); + expect(lastFrame()).toBe("sel=8 first=4"); + rerender(); + 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( + , + ); + stdin.write("8"); + await tick(); + rerender(); + await tick(); + // itemCount === 0 => no clamp, selection retained + expect(lastFrame()).toBe("sel=8 first=4"); + }); +}); diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 9329febf9..5db8bb1ae 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -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", diff --git a/clients/tui/package.json b/clients/tui/package.json index f1c5ff057..8ec425fa3 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -51,6 +51,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", diff --git a/clients/tui/vitest.config.ts b/clients/tui/vitest.config.ts index 2ead3aeb1..ca6483698 100644 --- a/clients/tui/vitest.config.ts +++ b/clients/tui/vitest.config.ts @@ -1,7 +1,7 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vitest/config'; -import { vitestSharedPaths } from '../../vitest.shared.mts'; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import { vitestSharedPaths } from "../../vitest.shared.mts"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const { projectResolve } = vitestSharedPaths(dirname); @@ -10,11 +10,11 @@ export default defineConfig({ resolve: projectResolve, test: { globals: false, - environment: 'node', - include: ['__tests__/**/*.test.ts'], + environment: "node", + include: ["__tests__/**/*.test.{ts,tsx}"], coverage: { - provider: 'v8', - reporter: ['text', 'html', 'json-summary'], + provider: "v8", + reporter: ["text", "html", "json-summary"], // INTERIM SCOPE (#1484). The TUI's UI is ~16 Ink/React components plus a // 1878-line App.tsx that need an Ink renderer (ink-testing-library) to // exercise — a large, separate effort tracked in its own follow-up issue. @@ -24,22 +24,32 @@ export default defineConfig({ // (utils/*). New non-React logic added under src/ is automatically held // to the gate; the React surface is explicitly excluded below until the // component-coverage follow-up (#1501) lands. - include: ['src/**/*.{ts,tsx}'], + include: ["src/**/*.{ts,tsx}"], exclude: [ // Pure re-export + type alias of core's server resolver (no runtime // statements of its own — the logic is measured in core via the web // suite). tui-servers.test.ts still exercises it behaviorally; it's // excluded here only so it doesn't surface as a misleading 0/0 row. - 'src/tui-servers.ts', - 'src/App.tsx', - // All Ink components (*.tsx). The sibling tabsConfig.ts (plain data, - // no JSX) stays in scope because this only excludes .tsx files. - 'src/components/**/*.tsx', - // useSelectableList is a React hook — needs a renderer like the - // components above. Folded into the interim React exclusion. - 'src/hooks/**', - '**/*.test.ts', - '**/*.d.ts', + "src/tui-servers.ts", + "src/App.tsx", + // Ink components still awaiting renderer-based tests (#1501). Entries + // are removed from this list as each component reaches the gate; the + // sibling tabsConfig.ts (plain data, no JSX) is already in scope. + "src/components/AuthTab.tsx", + "src/components/DetailsModal.tsx", + "src/components/HistoryTab.tsx", + "src/components/InfoTab.tsx", + "src/components/NotificationsTab.tsx", + "src/components/PromptTestModal.tsx", + "src/components/PromptsTab.tsx", + "src/components/RequestsTab.tsx", + "src/components/ResourceTestModal.tsx", + "src/components/ResourcesTab.tsx", + "src/components/ToolTestModal.tsx", + "src/components/ToolsTab.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.d.ts", ], thresholds: { perFile: true, From d8c2e9d3e813512390f9b7e709008e8c4c4d5312 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 27 Jun 2026 17:39:02 -0400 Subject: [PATCH 2/9] test(tui): add ink-scroll-view passthrough mock for renderer tests (#1501) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/helpers/inkScrollViewMock.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 clients/tui/__tests__/helpers/inkScrollViewMock.tsx diff --git a/clients/tui/__tests__/helpers/inkScrollViewMock.tsx b/clients/tui/__tests__/helpers/inkScrollViewMock.tsx new file mode 100644 index 000000000..c95292b3c --- /dev/null +++ b/clients/tui/__tests__/helpers/inkScrollViewMock.tsx @@ -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 {children}; +}); From c61db831a6a8987e530d44add872cd439133ceb3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 27 Jun 2026 17:56:35 -0400 Subject: [PATCH 3/9] test(tui): add ink-form passthrough mock for modal renderer tests (#1501) Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/tui/__tests__/HistoryTab.test.tsx | 270 ++++++++++++ clients/tui/__tests__/InfoTab.test.tsx | 396 ++++++++++++++++++ .../tui/__tests__/NotificationsTab.test.tsx | 179 ++++++++ clients/tui/__tests__/ToolsTab.test.tsx | 203 +++++++++ clients/tui/__tests__/helpers/inkFormMock.tsx | 38 ++ 5 files changed, 1086 insertions(+) create mode 100644 clients/tui/__tests__/HistoryTab.test.tsx create mode 100644 clients/tui/__tests__/InfoTab.test.tsx create mode 100644 clients/tui/__tests__/NotificationsTab.test.tsx create mode 100644 clients/tui/__tests__/ToolsTab.test.tsx create mode 100644 clients/tui/__tests__/helpers/inkFormMock.tsx diff --git a/clients/tui/__tests__/HistoryTab.test.tsx b/clients/tui/__tests__/HistoryTab.test.tsx new file mode 100644 index 000000000..b6d444b16 --- /dev/null +++ b/clients/tui/__tests__/HistoryTab.test.tsx @@ -0,0 +1,270 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 20)); + +// 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 => + ({ + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Direction: response"); + expect(frame).toContain("Response:"); + }); + + it("renders notification details with a Notification label", () => { + const { lastFrame } = render( + , + ); + 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( + , + ); + expect(lastFrame() ?? "").toContain("Message"); + }); + + it("moves selection with arrows and page keys when the list is focused", async () => { + const { lastFrame, stdin } = render( + , + ); + // 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( + , + ); + 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( + , + ); + stdin.write("+"); + await tick(); + expect(onViewDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/tui/__tests__/InfoTab.test.tsx b/clients/tui/__tests__/InfoTab.test.tsx new file mode 100644 index 000000000..44206569d --- /dev/null +++ b/clients/tui/__tests__/InfoTab.test.tsx @@ -0,0 +1,396 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(lastFrame() ?? "").toContain("Error"); + }); + + it("shows the footer and handles scroll keys when focused", async () => { + const { lastFrame, stdin } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("to scroll"); + + // Drive every useInput branch + stdin.write(""); // up arrow + await tick(); + stdin.write(""); // down arrow + await tick(); + stdin.write("[5~"); // pageUp + await tick(); + stdin.write("[6~"); // pageDown + await tick(); + // A non-handled key (no branch) to exercise the else fall-through + stdin.write("x"); + await tick(); + + expect(lastFrame() ?? "").toContain("Info"); + }); + + it("renders no config details for an unrecognized server type", () => { + // type is none of stdio / sse / streamable-http → the final `: null` + // ternary branch in the config block. + const config = { + type: "websocket", + url: "ws://example.com", + } as unknown as MCPServerConfig; + const { lastFrame } = render( + , + ); + 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( + , + ); + 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( + , + ); + expect(lastFrame() ?? "").not.toContain("to scroll"); + }); +}); diff --git a/clients/tui/__tests__/NotificationsTab.test.tsx b/clients/tui/__tests__/NotificationsTab.test.tsx new file mode 100644 index 000000000..e7ee62d55 --- /dev/null +++ b/clients/tui/__tests__/NotificationsTab.test.tsx @@ -0,0 +1,179 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(onCountChange).toHaveBeenCalledWith(2); + }); + + it("re-invokes onCountChange when the log count changes", async () => { + const onCountChange = vi.fn(); + const { rerender } = render( + , + ); + expect(onCountChange).toHaveBeenLastCalledWith(1); + + rerender( + , + ); + 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( + , + ); + expect(first).toHaveBeenLastCalledWith(1); + + // New callback identity + new count → the ref is updated, then the + // count-change effect fires the latest callback. + rerender( + , + ); + await tick(); + expect(second).toHaveBeenLastCalledWith(2); + }); + + it("renders without an onCountChange prop", () => { + const { lastFrame } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + stdin.write(DOWN); + await tick(); + expect(lastFrame() ?? "").toContain("line one"); + }); +}); diff --git a/clients/tui/__tests__/ToolsTab.test.tsx b/clients/tui/__tests__/ToolsTab.test.tsx new file mode 100644 index 000000000..65416a2a7 --- /dev/null +++ b/clients/tui/__tests__/ToolsTab.test.tsx @@ -0,0 +1,203 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 20)); + +// 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 => + ({ + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(lastFrame() ?? "").toContain("[Enter to Test]"); + }); + + it("moves selection down/up with arrow keys when the list is focused", async () => { + const { lastFrame, stdin } = render( + , + ); + // 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( + , + ); + // 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( + , + ); + 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( + , + ); + 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( + , + ); + // 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]); + }); +}); diff --git a/clients/tui/__tests__/helpers/inkFormMock.tsx b/clients/tui/__tests__/helpers/inkFormMock.tsx new file mode 100644 index 000000000..dbc8a5616 --- /dev/null +++ b/clients/tui/__tests__/helpers/inkFormMock.tsx @@ -0,0 +1,38 @@ +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. + * + * 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) { + const value = + (globalThis as Record).__INK_FORM_SUBMIT_VALUE__ ?? {}; + onSubmit?.(value as object); + } + }); + return ( + + MOCK_FORM:{form?.title ?? ""} + + ); +} From bfe057d447ed7911602dde7880748f24df34d831 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 27 Jun 2026 18:12:17 -0400 Subject: [PATCH 4/9] test(tui): cover all Ink tab + modal components via renderer tests (#1501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/tui/__tests__/AuthTab.test.tsx | 617 ++++++++++++++++++ clients/tui/__tests__/DetailsModal.test.tsx | 97 +++ .../tui/__tests__/PromptTestModal.test.tsx | 339 ++++++++++ clients/tui/__tests__/PromptsTab.test.tsx | 286 ++++++++ clients/tui/__tests__/RequestsTab.test.tsx | 359 ++++++++++ .../tui/__tests__/ResourceTestModal.test.tsx | 262 ++++++++ clients/tui/__tests__/ResourcesTab.test.tsx | 384 +++++++++++ clients/tui/__tests__/ToolTestModal.test.tsx | 275 ++++++++ clients/tui/vitest.config.ts | 33 +- 9 files changed, 2628 insertions(+), 24 deletions(-) create mode 100644 clients/tui/__tests__/AuthTab.test.tsx create mode 100644 clients/tui/__tests__/DetailsModal.test.tsx create mode 100644 clients/tui/__tests__/PromptTestModal.test.tsx create mode 100644 clients/tui/__tests__/PromptsTab.test.tsx create mode 100644 clients/tui/__tests__/RequestsTab.test.tsx create mode 100644 clients/tui/__tests__/ResourceTestModal.test.tsx create mode 100644 clients/tui/__tests__/ResourcesTab.test.tsx create mode 100644 clients/tui/__tests__/ToolTestModal.test.tsx diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx new file mode 100644 index 000000000..76af42fd5 --- /dev/null +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -0,0 +1,617 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 20)); + +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 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 => ({ + ...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( + , + ); + expect(lastFrame() ?? "").toContain("Select an OAuth-capable server"); + }); + + it("renders the placeholder when the server is not OAuth-capable", () => { + const { lastFrame } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + 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); + }); +}); diff --git a/clients/tui/__tests__/DetailsModal.test.tsx b/clients/tui/__tests__/DetailsModal.test.tsx new file mode 100644 index 000000000..52c7ac5fe --- /dev/null +++ b/clients/tui/__tests__/DetailsModal.test.tsx @@ -0,0 +1,97 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 20)); + +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( + some content} + 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( + scrollable} + 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( + x} + 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( + x} + width={120} + height={30} + onClose={onClose} + />, + ); + + await tick(); + stdin.write(ESC); + await tick(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/tui/__tests__/PromptTestModal.test.tsx b/clients/tui/__tests__/PromptTestModal.test.tsx new file mode 100644 index 000000000..4aec0abe5 --- /dev/null +++ b/clients/tui/__tests__/PromptTestModal.test.tsx @@ -0,0 +1,339 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 20)); + +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 => + ({ + 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 | undefined) { + if (value === undefined) { + delete (globalThis as Record).__INK_FORM_SUBMIT_VALUE__; + } else { + (globalThis as Record).__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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + , + ); + + 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( + , + ); + + 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( + {}} + />, + ); + + await tick(); + unmount(); + await tick(); + expect(true).toBe(true); + }); +}); diff --git a/clients/tui/__tests__/PromptsTab.test.tsx b/clients/tui/__tests__/PromptsTab.test.tsx new file mode 100644 index 000000000..87a87412b --- /dev/null +++ b/clients/tui/__tests__/PromptsTab.test.tsx @@ -0,0 +1,286 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 20)); + +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 => + ({ + 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( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + // 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + stdin.write("\r"); + await tick(); + expect(onFetchPrompt).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/tui/__tests__/RequestsTab.test.tsx b/clients/tui/__tests__/RequestsTab.test.tsx new file mode 100644 index 000000000..b77ab843e --- /dev/null +++ b/clients/tui/__tests__/RequestsTab.test.tsx @@ -0,0 +1,359 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 20)); + +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 => ({ + 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( + , + ); + 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( + , + ); + expect(lastFrame() ?? "").toContain("Requests (0)"); + }); + + it("renders the list with status colors, labels and durations", () => { + const { lastFrame } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(lastFrame() ?? "").toContain("Request in progress..."); + }); + + it("renders raw (non-JSON) request and response bodies", () => { + const { lastFrame } = render( + , + ); + 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( + , + ); + expect(lastFrame() ?? "").toContain("301"); + }); + + it("highlights the details header when the details pane is focused", () => { + const { lastFrame } = render( + , + ); + 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( + , + ); + // 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( + , + ); + // 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( + , + ); + 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( + , + ); + stdin.write("+"); + await tick(); + expect(onViewDetails).not.toHaveBeenCalled(); + }); + + it("ignores '+' when no onViewDetails handler is provided", async () => { + const { lastFrame, stdin } = render( + , + ); + stdin.write("+"); + await tick(); + // still rendered, no crash + expect(lastFrame() ?? "").toContain("GET https://example.com/oauth"); + }); +}); diff --git a/clients/tui/__tests__/ResourceTestModal.test.tsx b/clients/tui/__tests__/ResourceTestModal.test.tsx new file mode 100644 index 000000000..e5ae9cc11 --- /dev/null +++ b/clients/tui/__tests__/ResourceTestModal.test.tsx @@ -0,0 +1,262 @@ +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 = () => new Promise((resolve) => setTimeout(resolve, 0)); +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