diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx new file mode 100644 index 000000000..15f867603 --- /dev/null +++ b/clients/tui/__tests__/App.test.tsx @@ -0,0 +1,1133 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "ink-testing-library"; + +type RenderResult = ReturnType; + +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); +vi.mock("ink-form", () => import("./helpers/inkFormMock.js")); + +// --------------------------------------------------------------------------- +// Controllable mock of the entire @inspector/core surface App.tsx depends on. +// `ctrl` is mutated by individual tests (reset in beforeEach) to drive what the +// hooks return and what the InspectorClient methods do. +// --------------------------------------------------------------------------- +const h = vi.hoisted(() => { + interface Ctrl { + status: string; + capabilities: Record | null; + serverInfo: { name?: string; version?: string } | null; + instructions: string | null; + serverType: "stdio" | "sse" | "streamable-http"; + oauthFlowState: unknown; + tools: unknown[]; + resources: unknown[]; + resourceTemplates: unknown[]; + prompts: unknown[]; + messages: unknown[]; + fetchRequests: unknown[]; + stderrLogs: unknown[]; + } + const ctrl: Ctrl = { + status: "disconnected", + capabilities: null, + serverInfo: null, + instructions: null, + serverType: "stdio", + oauthFlowState: null, + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + messages: [], + fetchRequests: [], + stderrLogs: [], + }; + const connect = vi.fn().mockResolvedValue(undefined); + const disconnect = vi.fn().mockResolvedValue(undefined); + const openUrl = vi.fn().mockResolvedValue(undefined); + // Shared OAuth-related spies so a test can configure resolve/reject and + // assert calls regardless of which per-server FakeClient instance App built. + const clientSpies = { + authenticate: vi.fn( + async (): Promise => "https://auth.example/start", + ), + beginGuidedAuth: vi.fn(async (): Promise => {}), + runGuidedAuth: vi.fn(async (): Promise => undefined), + proceedOAuthStep: vi.fn(async (): Promise => {}), + clearOAuthTokens: vi.fn(), + completeOAuthFlow: vi.fn(async (): Promise => {}), + }; + // Captured options from the most recent callbackServer.start(), so a test can + // drive the onCallback / onError handlers the OAuth flows register. + interface CallbackOpts { + onCallback: (p: { code: string }) => Promise | void; + onError: (p: { error?: string; error_description?: string }) => void; + } + const cb: { opts: CallbackOpts | null } = { opts: null }; + const callbackStart = vi.fn(async (opts: CallbackOpts) => { + cb.opts = opts; + return { redirectUrl: "http://localhost/cb" }; + }); + const callbackStop = vi.fn().mockResolvedValue(undefined); + const createOAuthCallbackServer = vi.fn(() => ({ + start: callbackStart, + stop: callbackStop, + })); + class FakeManager { + destroy = vi.fn(); + } + class FakeClient { + cfg: { type?: string } | undefined; + constructor(config?: { type?: string }) { + this.cfg = config; + } + // Derive the transport type from the server config the client was built + // with (config.type aligns with the serverType union) so per-server gating + // (logging/requests tabs) works in mixed catalogs; fall back to ctrl. + getServerType = vi.fn( + () => + (this.cfg?.type ?? ctrl.serverType) as + | "stdio" + | "sse" + | "streamable-http", + ); + getOAuthFlowState = vi.fn(() => ctrl.oauthFlowState); + authenticate = (...a: unknown[]) => clientSpies.authenticate(...a); + beginGuidedAuth = (...a: unknown[]) => clientSpies.beginGuidedAuth(...a); + runGuidedAuth = (...a: unknown[]) => clientSpies.runGuidedAuth(...a); + proceedOAuthStep = (...a: unknown[]) => clientSpies.proceedOAuthStep(...a); + clearOAuthTokens = (...a: unknown[]) => clientSpies.clearOAuthTokens(...a); + completeOAuthFlow = (...a: unknown[]) => + clientSpies.completeOAuthFlow(...a); + readResource = vi.fn(async () => ({ + result: { contents: [{ uri: "file://x", text: "hello" }] }, + })); + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + // Reject so the unmount cleanup's `.catch(() => {})` arrow is exercised. + disconnect = vi.fn().mockRejectedValue(new Error("cleanup disconnect")); + } + return { + ctrl, + connect, + disconnect, + openUrl, + clientSpies, + cb, + createOAuthCallbackServer, + callbackStart, + callbackStop, + FakeManager, + FakeClient, + useInspectorClient: vi.fn(() => ({ + status: ctrl.status, + capabilities: ctrl.capabilities, + serverInfo: ctrl.serverInfo, + instructions: ctrl.instructions, + connect, + disconnect, + })), + useManagedTools: vi.fn(() => ({ tools: ctrl.tools })), + useManagedResources: vi.fn(() => ({ resources: ctrl.resources })), + useManagedResourceTemplates: vi.fn(() => ({ + resourceTemplates: ctrl.resourceTemplates, + })), + useManagedPrompts: vi.fn(() => ({ prompts: ctrl.prompts })), + useMessageLog: vi.fn(() => ({ messages: ctrl.messages })), + useFetchRequestLog: vi.fn(() => ({ fetchRequests: ctrl.fetchRequests })), + useStderrLog: vi.fn(() => ({ stderrLogs: ctrl.stderrLogs })), + }; +}); + +vi.mock("@inspector/core/mcp/index.js", () => ({ + InspectorClient: h.FakeClient, +})); +vi.mock("@inspector/core/mcp/state/index.js", () => ({ + ManagedToolsState: h.FakeManager, + ManagedResourcesState: h.FakeManager, + ManagedResourceTemplatesState: h.FakeManager, + ManagedPromptsState: h.FakeManager, + MessageLogState: h.FakeManager, + FetchRequestLogState: h.FakeManager, + StderrLogState: h.FakeManager, +})); +vi.mock("@inspector/core/mcp/node/index.js", () => ({ + createTransportNode: vi.fn(), +})); +vi.mock("@inspector/core/react/useInspectorClient.js", () => ({ + useInspectorClient: h.useInspectorClient, +})); +vi.mock("@inspector/core/react/useManagedTools.js", () => ({ + useManagedTools: h.useManagedTools, +})); +vi.mock("@inspector/core/react/useManagedResources.js", () => ({ + useManagedResources: h.useManagedResources, +})); +vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({ + useManagedResourceTemplates: h.useManagedResourceTemplates, +})); +vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({ + useManagedPrompts: h.useManagedPrompts, +})); +vi.mock("@inspector/core/react/useMessageLog.js", () => ({ + useMessageLog: h.useMessageLog, +})); +vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({ + useFetchRequestLog: h.useFetchRequestLog, +})); +vi.mock("@inspector/core/react/useStderrLog.js", () => ({ + useStderrLog: h.useStderrLog, +})); +vi.mock("@inspector/core/auth/index.js", () => ({ + CallbackNavigation: class {}, + MutableRedirectUrlProvider: class { + redirectUrl = ""; + }, +})); +vi.mock("@inspector/core/auth/node/index.js", () => ({ + createOAuthCallbackServer: h.createOAuthCallbackServer, + NodeOAuthStorage: class {}, +})); +vi.mock("../src/utils/openUrl.js", () => ({ + openUrl: h.openUrl, +})); + +import App from "../src/App.js"; +import type { TuiServer } from "../src/tui-servers.js"; + +const tick = () => new Promise((r) => setTimeout(r, 25)); +const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" }; + +function stdioServer(): Record { + return { + alpha: { + config: { type: "stdio", command: "node", args: ["s.js"] }, + } as never, + beta: { + config: { type: "stdio", command: "node", args: ["b.js"] }, + } as never, + }; +} + +function httpServer(): Record { + return { + web: { config: { type: "streamable-http", url: "http://x" } } as never, + }; +} + +// Single-server catalogs auto-select their only server on mount, so action +// tests can drive accelerators without first navigating the server list. +function oneStdio(): Record { + return { + alpha: { + config: { type: "stdio", command: "node", args: ["s.js"] }, + } as never, + }; +} + +// Single streamable-http server catalog (auto-selected on mount). +function oneHttp(): Record { + return { + web: { config: { type: "streamable-http", url: "http://x" } } as never, + }; +} + +// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by +// a stdio server — drives per-server tab gating + the tab-switch-away effects. +function httpThenStdio(): Record { + return { + web: { config: { type: "streamable-http", url: "http://x" } } as never, + cli: { + config: { type: "stdio", command: "node", args: ["s.js"] }, + } as never, + }; +} + +function stdioThenHttp(): Record { + return { + cli: { + config: { type: "stdio", command: "node", args: ["s.js"] }, + } as never, + web: { config: { type: "streamable-http", url: "http://x" } } as never, + }; +} + +// An http server carrying saved settings (metadata, oauth creds, timeout) to +// exercise the per-server option-building branches in the mount effect. +function httpWithSettings(): Record { + return { + web: { + config: { type: "streamable-http", url: "http://x" }, + settings: { + requestTimeout: 5000, + metadata: [ + { key: "team", value: "alpha" }, + { key: " ", value: "ignored" }, + ], + oauthClientId: "cid", + oauthClientSecret: "secret", + oauthScopes: "read write", + }, + } as never, + }; +} + +// Realistic minimal fixtures for tab content / details modals. +const sampleTool = { + name: "alpha", + description: "Tool desc line1\nline2", + inputSchema: { type: "object", properties: { x: { type: "string" } } }, +}; +const sampleResource = { + name: "res1", + uri: "file://x", + description: "rdesc", + mimeType: "text/plain", +}; +const sampleTemplate = { + name: "tmpl1", + uriTemplate: "file://{id}", + description: "tdesc", +}; +const promptWithArgs = { + name: "p1", + description: "pdesc", + arguments: [{ name: "arg1", description: "a1" }], +}; +const promptNoName = { name: "", description: "no name prompt" }; +const reqMessage = { + id: "m1", + direction: "request", + message: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + response: { jsonrpc: "2.0", id: 1, result: {} }, + timestamp: new Date(0), + duration: 5, +}; +const notifMessage = { + id: "m2", + direction: "notification", + message: { jsonrpc: "2.0", method: "notifications/message" }, + timestamp: new Date(0), +}; +const fullRequest = { + id: "r1", + method: "POST", + url: "http://x/mcp", + category: "transport", + responseStatus: 200, + responseStatusText: "OK", + duration: 12, + timestamp: new Date(0), + requestHeaders: { "content-type": "application/json" }, + requestBody: JSON.stringify({ a: 1 }), + responseHeaders: { "x-h": "v" }, + responseBody: JSON.stringify({ ok: true }), +}; +const errorRequest = { + id: "r2", + method: "GET", + url: "http://x/auth", + category: "auth", + error: "boom", + timestamp: new Date(0), + requestHeaders: { accept: "*/*" }, + requestBody: "not json{", + responseBody: "also not json{", +}; +const respMessage = { + id: "m3", + direction: "response", + message: { jsonrpc: "2.0", id: 2, result: { ok: true } }, + timestamp: new Date(0), +}; +const bareRequest = { + id: "r3", + method: "GET", + url: "http://x/idle", + category: "transport", + timestamp: new Date(0), + requestHeaders: {}, +}; +const stderrLog = { timestamp: new Date(0), message: "log line" }; + +// Track rendered instances so each is unmounted after its test — concurrent +// mounted ink apps share raw-mode stdin handling and interfere with useInput. +const mounted: RenderResult[] = []; + +function renderApp(servers: Record) { + const r = render( + , + ); + mounted.push(r); + return r; +} + +/** + * Render and absorb ink-testing-library's intermittently-dropped first + * keypress with a benign no-op key ("x" is not bound while the server list is + * focused), so subsequent navigation keys register deterministically. + */ +async function mount(servers: Record) { + const r = renderApp(servers); + await tick(); + r.stdin.write("x"); + await tick(); + return r; +} + +// Arrow / shift-tab only parse as those keys when ESC-prefixed (a bare "[B" is +// read as the literal characters). Tab and Enter are real control characters. +const ESC = String.fromCharCode(27); +const DOWN = `${ESC}[B`; +const UP = `${ESC}[A`; +const RIGHT = `${ESC}[C`; +const LEFT = `${ESC}[D`; +const STAB = `${ESC}[Z`; +const TAB = "\t"; +const ENTER = "\r"; + +/** + * Write each key in order. ink re-subscribes the active component's useInput on + * re-render, so a key that changes focus/tab must let that re-render flush + * before the next key — otherwise the next key is routed to the old handler. + * Two ticks per key keeps multi-step navigation deterministic under the heavier + * v8 coverage instrumentation (where a single tick can race the render). + */ +async function press(r: RenderResult, keys: string[]) { + for (const k of keys) { + r.stdin.write(k); + await tick(); + await tick(); + } +} + +/** + * Poll until `predicate` is true (or the tries run out). React + ink schedule + * renders across several macrotasks, so async state set by a flow can take more + * than one fixed tick to land under coverage instrumentation. + */ +async function waitUntil(predicate: () => boolean, tries = 25) { + for (let i = 0; i < tries; i++) { + if (predicate()) return; + await tick(); + } +} + +/** + * Poll the frame until it contains `substr` (or the tries run out). Render + * settling races a single fixed tick under v8 coverage instrumentation, so + * frame assertions that follow a mount/keypress use this instead of one tick. + */ +async function waitForFrame(r: RenderResult, substr: string, tries = 25) { + await waitUntil(() => (r.lastFrame() ?? "").includes(substr), tries); +} + +/** Poll until the frame contains `substr`, then assert it — stable under load. */ +async function expectFrame(r: RenderResult, substr: string) { + await waitForFrame(r, substr); + expect(r.lastFrame() ?? "").toContain(substr); +} + +beforeEach(() => { + Object.assign(h.ctrl, { + status: "disconnected", + capabilities: null, + serverInfo: null, + instructions: null, + serverType: "stdio", + oauthFlowState: null, + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + messages: [], + fetchRequests: [], + stderrLogs: [], + }); + h.connect.mockClear(); + h.connect.mockResolvedValue(undefined); + h.disconnect.mockClear(); + h.disconnect.mockResolvedValue(undefined); + h.openUrl.mockClear(); + h.openUrl.mockResolvedValue(undefined); + h.cb.opts = null; + h.callbackStart.mockClear(); + h.callbackStop.mockClear(); + h.clientSpies.authenticate.mockReset(); + h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start"); + h.clientSpies.beginGuidedAuth.mockReset(); + h.clientSpies.beginGuidedAuth.mockResolvedValue(undefined); + h.clientSpies.runGuidedAuth.mockReset(); + h.clientSpies.runGuidedAuth.mockResolvedValue(undefined); + h.clientSpies.proceedOAuthStep.mockReset(); + h.clientSpies.proceedOAuthStep.mockResolvedValue(undefined); + h.clientSpies.clearOAuthTokens.mockReset(); + h.clientSpies.completeOAuthFlow.mockReset(); + h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined); +}); + +afterEach(() => { + while (mounted.length) mounted.pop()?.unmount(); +}); + +describe("App (foundation)", () => { + it("renders the server list with the MCP Servers header", async () => { + const r = renderApp(stdioServer()); + await expectFrame(r, "MCP Servers"); + const frame = r.lastFrame() ?? ""; + expect(frame).toContain("alpha"); + expect(frame).toContain("beta"); + }); + + it("auto-selects the first server and shows its config", async () => { + const r = renderApp(stdioServer()); + await expectFrame(r, "Server Configuration"); + }); + + it("moves selection down to the next server with the down arrow", async () => { + const r = await mount(stdioServer()); + await press(r, [DOWN]); // alpha -> beta + await expectFrame(r, "b.js"); + }); + + it("connects with 'c' when disconnected", async () => { + const { stdin } = await mount(oneStdio()); + stdin.write("c"); + await tick(); + expect(h.connect).toHaveBeenCalled(); + }); + + it("disconnects with 'd' when connected", async () => { + h.ctrl.status = "connected"; + const { stdin } = await mount(oneStdio()); + stdin.write("d"); + await tick(); + expect(h.disconnect).toHaveBeenCalled(); + }); + + it("switches tabs via accelerator keys", async () => { + const r = await mount(stdioServer()); + await press(r, ["t"]); // tools tab (server is auto-selected) + await expectFrame(r, "Tools"); + }); + + it("cycles focus with tab and shift+tab", async () => { + const { stdin } = renderApp(stdioServer()); + await tick(); + stdin.write("[B"); + await tick(); + stdin.write("\t"); // forward + await tick(); + // shift+tab is delivered as ESC [ Z + stdin.write(""); + await tick(); + // no assertion on hidden focus state — exercising the branches + }); + + it("shows the Auth tab and G/Q/S accelerators for an OAuth-capable server", async () => { + h.ctrl.serverType = "streamable-http"; + const r = await mount(httpServer()); + await press(r, ["g"]); // guided auth accelerator -> Auth tab + await expectFrame(r, "Auth"); + }); + + it("renders connected status with capabilities", async () => { + h.ctrl.status = "connected"; + h.ctrl.capabilities = { tools: {}, resources: {}, prompts: {} }; + h.ctrl.serverInfo = { name: "srv", version: "1.0.0" }; + const r = await mount(oneStdio()); + await expectFrame(r, "connected"); + }); +}); + +describe("App (status, layout, modals)", () => { + it("renders the connecting status symbol/color", async () => { + h.ctrl.status = "connecting"; + const r = await mount(oneStdio()); + await expectFrame(r, "connecting"); + }); + + it("renders the error status symbol/color", async () => { + h.ctrl.status = "error"; + const r = await mount(oneStdio()); + await expectFrame(r, "error"); + }); + + it("shows the 401 auth hint for an http server with a 401 response", async () => { + h.ctrl.status = "error"; + h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }]; + const r = await mount(oneHttp()); + await expectFrame(r, "401 Unauthorized"); + }); + + it("updates dimensions when the terminal resizes", async () => { + const r = await mount(oneStdio()); + process.stdout.emit("resize"); + await tick(); + await expectFrame(r, "MCP Servers"); + }); + + it("renders Tools tab content when connected", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneStdio()); + await press(r, ["t"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Tools (1)"); + expect(f).toContain("alpha"); + }); + + it("opens the tool test modal with Enter from the list pane", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneStdio()); + await press(r, ["t", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ESC]); // ESC closes the modal + expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM"); + }); + + it("opens the tool details modal with '+' and closes it on ESC", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneStdio()); + await press(r, ["t", TAB, TAB, "+"]); + await expectFrame(r, "Input Schema:"); + expect(r.lastFrame() ?? "").toContain("Full JSON:"); + await press(r, [ESC]); + expect(r.lastFrame() ?? "").not.toContain("Full JSON:"); + }); + + it("fetches a resource and opens its details modal", async () => { + h.ctrl.status = "connected"; + h.ctrl.resources = [sampleResource]; + const r = await mount(oneStdio()); + await press(r, ["r", TAB, ENTER]); + await tick(); + await press(r, [TAB, "+"]); + await expectFrame(r, "Full JSON:"); + }); + + it("opens the resource template test modal via Enter on a template", async () => { + h.ctrl.status = "connected"; + h.ctrl.resources = [sampleResource]; + h.ctrl.resourceTemplates = [sampleTemplate]; + const r = await mount(oneStdio()); + await press(r, ["r", TAB, DOWN, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ESC]); + expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM"); + }); + + it("opens the prompt test modal via Enter on a prompt with arguments", async () => { + h.ctrl.status = "connected"; + h.ctrl.prompts = [promptWithArgs]; + const r = await mount(oneStdio()); + await press(r, ["p", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ESC]); + expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM"); + }); + + it("opens the prompt details modal with '+'", async () => { + h.ctrl.status = "connected"; + h.ctrl.prompts = [promptWithArgs]; + const r = await mount(oneStdio()); + await press(r, ["p", TAB, TAB, "+"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Arguments:"); + expect(f).toContain("Full JSON:"); + }); + + it("opens details for a nameless prompt with no arguments", async () => { + h.ctrl.status = "connected"; + h.ctrl.prompts = [promptNoName]; + const r = await mount(oneStdio()); + await press(r, ["p", TAB, TAB, "+"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Prompt: Unknown"); + expect(f).not.toContain("Arguments:"); + }); + + it("opens message details for a request message (with response)", async () => { + h.ctrl.messages = [reqMessage]; + const r = await mount(oneStdio()); + await press(r, ["m", TAB, TAB, "+"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Direction: request"); + expect(f).toContain("Response:"); + }); + + it("opens message details for a notification message", async () => { + h.ctrl.messages = [notifMessage]; + const r = await mount(oneStdio()); + await press(r, ["m", TAB, TAB, "+"]); + await expectFrame(r, "Notification:"); + }); + + it("opens message details for a response message", async () => { + h.ctrl.messages = [respMessage]; + const r = await mount(oneStdio()); + await press(r, ["m", TAB, TAB, "+"]); + await expectFrame(r, "Response:"); + }); + + it("opens in-progress request details (no status, error, or bodies)", async () => { + h.ctrl.status = "connected"; + h.ctrl.fetchRequests = [bareRequest]; + const r = await mount(oneHttp()); + await press(r, ["h", TAB, TAB, "+"]); + await expectFrame(r, "Request Headers:"); + }); + + it("connects with 'c' from the error state", async () => { + h.ctrl.status = "error"; + const r = await mount(oneStdio()); + await press(r, ["c"]); + await tick(); + expect(h.connect).toHaveBeenCalled(); + }); + + it("disconnects with 'd' from the connecting state", async () => { + h.ctrl.status = "connecting"; + const r = await mount(oneStdio()); + await press(r, ["d"]); + await tick(); + expect(h.disconnect).toHaveBeenCalled(); + }); + + it("renders the HTTP requests tab and opens full request details", async () => { + h.ctrl.status = "connected"; + h.ctrl.fetchRequests = [fullRequest]; + const r = await mount(oneHttp()); + await press(r, ["h", TAB, TAB, "+"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Request Headers:"); + expect(f).toContain("Status: 200"); + }); + + it("opens error-request details (error branch + unparseable bodies)", async () => { + h.ctrl.status = "connected"; + h.ctrl.fetchRequests = [errorRequest]; + const r = await mount(oneHttp()); + await press(r, ["h", TAB, TAB, "+"]); + await expectFrame(r, "Error: boom"); + }); + + it("renders the Logging tab for a stdio server", async () => { + h.ctrl.status = "connected"; + h.ctrl.stderrLogs = [stderrLog]; + const r = await mount(oneStdio()); + await press(r, ["l"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Logging (1)"); + expect(f).toContain("log line"); + }); +}); + +describe("App (input handling, focus, effects)", () => { + it("switches tabs with left/right arrows when the tabs row is focused", async () => { + h.ctrl.status = "connected"; + const r = await mount(oneHttp()); + await press(r, [TAB]); // serverList -> tabs + await press(r, [LEFT, RIGHT, RIGHT, LEFT]); // wrap + cycle both directions + await expectFrame(r, "MCP Servers"); + }); + + it("switches tabs with arrows on a stdio server (logging tab, no requests)", async () => { + h.ctrl.status = "connected"; + const r = await mount(oneStdio()); + await press(r, [TAB]); // serverList -> tabs + await press(r, [RIGHT, RIGHT, LEFT]); + await expectFrame(r, "MCP Servers"); + }); + + it("updates the resources tab count when the resource list changes", async () => { + h.ctrl.status = "connected"; + h.ctrl.resources = []; + const r = await mount(oneStdio()); + await press(r, ["r"]); + h.ctrl.resources = [sampleResource]; + await press(r, [TAB]); // a focus change forces a re-render with new resources + await tick(); + await expectFrame(r, "Resources (1)"); + }); + + it("exits on Ctrl+C", async () => { + const r = await mount(oneStdio()); + r.stdin.write("\x03"); // ETX -> ctrl+c + await tick(); + expect(r.lastFrame() ?? "").toBeDefined(); + }); + + it("exits on Escape", async () => { + const r = await mount(oneStdio()); + await press(r, [ESC]); + expect(r.lastFrame() ?? "").toBeDefined(); + }); + + it("moves and wraps server selection with up and down arrows", async () => { + const r = await mount(stdioServer()); // alpha(0), beta(1); alpha selected + await press(r, [DOWN]); // alpha -> beta (down, index+1) + await press(r, [UP]); // beta -> alpha (up, index-1) + await press(r, [UP]); // alpha -> beta (up wrap to last) + await press(r, [DOWN]); // beta -> alpha (down wrap to first) + await expectFrame(r, "Server Configuration"); + }); + + it("handles arrow keys with an empty server catalog", async () => { + const r = await mount({}); + await press(r, [UP, DOWN]); + await expectFrame(r, "MCP Servers"); + }); + + it("cycles focus order through the messages tab panes", async () => { + h.ctrl.messages = [reqMessage]; + const r = await mount(oneStdio()); + await press(r, ["m"]); + await press(r, [TAB, TAB, TAB, TAB]); // forward through messages focusOrder + await press(r, [STAB, STAB]); // reverse + await expectFrame(r, "Messages"); + }); + + it("cycles focus order through the requests tab panes", async () => { + h.ctrl.status = "connected"; + h.ctrl.fetchRequests = [fullRequest]; + const r = await mount(oneHttp()); + await press(r, ["h"]); + await press(r, [TAB, TAB, TAB, TAB]); + await press(r, [STAB, STAB]); + await expectFrame(r, "Requests"); + }); + + it("switches away from the Auth tab when selecting a non-OAuth server", async () => { + const r = await mount(httpThenStdio()); + await press(r, ["a"]); // Auth tab (http is OAuth-capable) + await press(r, [STAB]); // tabs -> serverList + await press(r, [DOWN]); // select the stdio server -> effect leaves Auth + await expectFrame(r, "Server Configuration"); + }); + + it("switches away from the Logging tab when selecting a non-stdio server", async () => { + const r = await mount(stdioThenHttp()); + await press(r, ["l"]); // Logging tab (stdio) + await press(r, [STAB]); // tabs -> serverList + await press(r, [DOWN]); // select the http server -> effect leaves Logging + await expectFrame(r, "Server Configuration"); + }); + + it("swallows connect errors", async () => { + h.connect.mockRejectedValue(new Error("connfail")); + const r = await mount(oneStdio()); + await press(r, ["c"]); + await tick(); + expect(h.connect).toHaveBeenCalled(); + }); + + it("builds a client with saved settings (metadata, oauth, timeout)", async () => { + const r = await mount(httpWithSettings()); + await expectFrame(r, "MCP Servers"); + }); + + it("passes top-level oauth client credentials into an http client", async () => { + const r = render( + , + ); + mounted.push(r); + await tick(); + await expectFrame(r, "MCP Servers"); + }); +}); + +describe("App (OAuth flows)", () => { + it("runs quick auth to success when no auth URL is returned", async () => { + h.clientSpies.authenticate.mockResolvedValue(undefined); + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await tick(); + expect(h.callbackStart).toHaveBeenCalled(); + expect(h.clientSpies.authenticate).toHaveBeenCalled(); + await expectFrame(r, "OAuth complete"); + }); + + it("completes quick auth when the OAuth callback fires", async () => { + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + await h.cb.opts!.onCallback({ code: "abc" }); + await tick(); + expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("abc"); + await expectFrame(r, "OAuth complete"); + }); + + it("reports an error when the OAuth callback errors", async () => { + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + h.cb.opts!.onError({ error_description: "denied" }); + await tick(); + await expectFrame(r, "denied"); + }); + + it("runs guided auth to completion and opens the auth URL", async () => { + h.clientSpies.runGuidedAuth.mockResolvedValue("http://auth/x"); + const r = await mount(oneHttp()); + await press(r, ["g", ENTER]); + await tick(); + expect(h.clientSpies.runGuidedAuth).toHaveBeenCalled(); + expect(h.openUrl).toHaveBeenCalledWith("http://auth/x"); + }); + + it("reports an error when guided-to-completion fails", async () => { + h.clientSpies.runGuidedAuth.mockRejectedValue(new Error("nope")); + const r = await mount(oneHttp()); + await press(r, ["g", ENTER]); + await tick(); + await expectFrame(r, "nope"); + }); + + it("starts guided auth then advances a step, opening the auth URL", async () => { + const r = await mount(oneHttp()); + await press(r, ["g", " "]); // Space starts the guided flow + await tick(); + h.ctrl.oauthFlowState = { + oauthStep: "authorization_code", + authorizationUrl: "http://auth/code", + }; + await press(r, [" "]); // Space again advances one step + await tick(); + expect(h.clientSpies.beginGuidedAuth).toHaveBeenCalled(); + expect(h.clientSpies.proceedOAuthStep).toHaveBeenCalled(); + expect(h.openUrl).toHaveBeenCalledWith("http://auth/code"); + }); + + it("advances guided auth without opening a URL", async () => { + h.ctrl.oauthFlowState = { oauthStep: "token_request" }; + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await tick(); + await press(r, [" "]); + await tick(); + expect(h.clientSpies.proceedOAuthStep).toHaveBeenCalled(); + expect(h.openUrl).not.toHaveBeenCalled(); + }); + + it("reports an error when guided start fails", async () => { + h.clientSpies.beginGuidedAuth.mockRejectedValue(new Error("startfail")); + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await tick(); + await expectFrame(r, "startfail"); + }); + + it("reports an error when advancing guided auth fails", async () => { + h.clientSpies.proceedOAuthStep.mockRejectedValue(new Error("advfail")); + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await tick(); + await press(r, [" "]); + await tick(); + await expectFrame(r, "advfail"); + }); + + it("clears OAuth state via the Clear action", async () => { + const r = await mount(oneHttp()); + await press(r, ["s", ENTER]); + await tick(); + expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled(); + await expectFrame(r, "OAuth state cleared"); + }); + + it("reports an error when quick callback completion fails", async () => { + h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("qcfail")); + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + await h.cb.opts!.onCallback({ code: "x" }); + await tick(); + await expectFrame(r, "qcfail"); + }); + + it("stops a prior callback server before starting quick auth again", async () => { + h.clientSpies.authenticate.mockResolvedValue(undefined); + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); // first run completes, leaving the server set + await tick(); + await press(r, ["q", ENTER]); // second run stops the prior server + await tick(); + expect(h.callbackStop).toHaveBeenCalled(); + }); + + it("completes guided auth when the callback fires", async () => { + const r = await mount(oneHttp()); + await press(r, ["g", " "]); // Space starts guided + registers callback server + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + await h.cb.opts!.onCallback({ code: "gc" }); + await tick(); + expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("gc"); + await expectFrame(r, "OAuth complete"); + }); + + it("reports an error when the guided callback errors", async () => { + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + h.cb.opts!.onError({ error: "guided-bad" }); + await tick(); + await expectFrame(r, "guided-bad"); + }); + + it("reports an error when guided callback completion fails", async () => { + h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("gfail")); + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await waitUntil(() => h.cb.opts !== null); + await h.cb.opts!.onCallback({ code: "x" }); + await tick(); + await expectFrame(r, "gfail"); + }); + + it("completes run-to-completion auth when the callback fires", async () => { + const r = await mount(oneHttp()); + await press(r, ["g", ENTER]); // runs to completion -> ensureCallbackServer + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + await h.cb.opts!.onCallback({ code: "rc" }); + await tick(); + expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("rc"); + await expectFrame(r, "OAuth complete"); + }); + + it("reports an error when the run-to-completion callback errors", async () => { + const r = await mount(oneHttp()); + await press(r, ["g", ENTER]); + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + h.cb.opts!.onError({ error: "rc-bad" }); + await tick(); + await expectFrame(r, "rc-bad"); + }); + + it("stringifies a non-Error quick auth rejection", async () => { + h.clientSpies.authenticate.mockRejectedValue("plainstring"); + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await tick(); + await expectFrame(r, "plainstring"); + }); + + it("uses the default OAuth error label when the callback error is empty", async () => { + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + h.cb.opts!.onError({}); + await tick(); + await expectFrame(r, "OAuth error"); + }); + + it("stringifies a non-Error guided callback completion failure", async () => { + h.clientSpies.completeOAuthFlow.mockRejectedValue("guided-string"); + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await waitUntil(() => h.cb.opts !== null); + await h.cb.opts!.onCallback({ code: "x" }); + await tick(); + await expectFrame(r, "guided-string"); + }); + + it("falls back to params.error when the quick callback has no description", async () => { + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await waitUntil(() => h.cb.opts !== null); + h.cb.opts!.onError({ error: "quick-error-code" }); + await tick(); + await expectFrame(r, "quick-error-code"); + }); + + it("uses the default label when the guided callback error is empty", async () => { + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await waitUntil(() => h.cb.opts !== null); + h.cb.opts!.onError({}); + await tick(); + await expectFrame(r, "OAuth error"); + }); + + it("uses the default label when the run-to-completion error is empty", async () => { + const r = await mount(oneHttp()); + await press(r, ["g", ENTER]); + await waitUntil(() => h.cb.opts !== null); + h.cb.opts!.onError({}); + await tick(); + await expectFrame(r, "OAuth error"); + }); + + it("wraps a non-Error quick callback rejection into an Error", async () => { + h.clientSpies.completeOAuthFlow.mockRejectedValue("quick-cb-string"); + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await waitUntil(() => h.cb.opts !== null); + expect(h.cb.opts).not.toBeNull(); + await h.cb.opts!.onCallback({ code: "x" }); + await tick(); + // quick auth's onCallback wraps the throw via new Error(String(err)); the + // flow rejects and the catch surfaces the stringified message. + await expectFrame(r, "quick-cb-string"); + }); + + it("stringifies a non-Error guided-start rejection", async () => { + h.clientSpies.beginGuidedAuth.mockRejectedValue("startfail-string"); + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await tick(); + await expectFrame(r, "startfail-string"); + }); + + it("stringifies a non-Error guided-advance rejection", async () => { + h.clientSpies.proceedOAuthStep.mockRejectedValue("advfail-string"); + const r = await mount(oneHttp()); + await press(r, ["g", " "]); + await tick(); + await press(r, [" "]); + await tick(); + await expectFrame(r, "advfail-string"); + }); + + it("stringifies a non-Error run-to-completion rejection", async () => { + h.clientSpies.runGuidedAuth.mockRejectedValue("runfail-string"); + const r = await mount(oneHttp()); + await press(r, ["g", ENTER]); + await tick(); + await expectFrame(r, "runfail-string"); + }); + + it("ignores a re-entrant quick-auth trigger while one is in progress", async () => { + // Hold the first flow open by leaving authenticate pending, so the second + // 'q' hits the `if (oauthInProgressRef.current) return` guard. + let release!: () => void; + h.clientSpies.authenticate.mockImplementation( + () => new Promise((res) => (release = () => res(undefined))), + ); + const r = await mount(oneHttp()); + await press(r, ["q", ENTER]); + await tick(); + await press(r, ["q", ENTER]); // re-entrant: guarded out + await tick(); + expect(h.callbackStart).toHaveBeenCalledTimes(1); + release(); + await tick(); + }); +}); diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx new file mode 100644 index 000000000..b3d42a887 --- /dev/null +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -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 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..41689568d --- /dev/null +++ b/clients/tui/__tests__/DetailsModal.test.tsx @@ -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( + 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__/HistoryTab.test.tsx b/clients/tui/__tests__/HistoryTab.test.tsx new file mode 100644 index 000000000..729d88c62 --- /dev/null +++ b/clients/tui/__tests__/HistoryTab.test.tsx @@ -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 => + ({ + 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..543b6fde3 --- /dev/null +++ b/clients/tui/__tests__/InfoTab.test.tsx @@ -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( + , + ); + 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..ed51b39b8 --- /dev/null +++ b/clients/tui/__tests__/NotificationsTab.test.tsx @@ -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( + , + ); + 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__/PromptTestModal.test.tsx b/clients/tui/__tests__/PromptTestModal.test.tsx new file mode 100644 index 000000000..1a1d05825 --- /dev/null +++ b/clients/tui/__tests__/PromptTestModal.test.tsx @@ -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 => + ({ + 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..c36a8fc00 --- /dev/null +++ b/clients/tui/__tests__/PromptsTab.test.tsx @@ -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 => + ({ + 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..8c504eb15 --- /dev/null +++ b/clients/tui/__tests__/RequestsTab.test.tsx @@ -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 => ({ + 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..37103ba77 --- /dev/null +++ b/clients/tui/__tests__/ResourceTestModal.test.tsx @@ -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