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. // Each spy is typed against the real InspectorClient method signature so its // implementation and `mockResolvedValue` / return payloads stay in sync with // the client (this is what keeps a stale `{ kind: "satisfied" }` literal from // narrowing `handleAuthChallenge`'s return). Note vitest does NOT type-check // `toHaveBeenCalledWith(...)` arguments against the mock's signature, so those // assertions stay runtime-only. The FakeClient wrappers below forward the same // `Parameters<…>` tuple, which spreads cleanly (a tuple, not `unknown[]`). const clientSpies = { authenticate: vi.fn( async () => new URL("https://auth.example/start"), ), clearOAuthTokens: vi.fn( async () => {}, ), completeOAuthFlow: vi.fn( async () => {}, ), getOAuthState: vi.fn( async () => undefined, ), callTool: vi.fn(), checkAuthChallengeSatisfied: vi.fn< InspectorClient["checkAuthChallengeSatisfied"] >(async () => false), handleAuthChallenge: vi.fn( async () => ({ kind: "satisfied" }), ), }; // 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; iss?: 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, })); // Registry of the auth-lifecycle listeners App registers per client, so a test // can fire authChallengeAmbient / authChallengeRecovered / authChallengeInteractive // / oauthError against whichever FakeClient instance App built. // Each entry records which FakeClient registered the handler so a test can // fire an event for a single client (`fireClientEventFor`) — needed to truly // assert the per-server `selectedServerRef.current !== serverName` guards, // not merely execute them. `fireClientEvent` still fires every client's // handler for the event (the common single-server case). type EventEntry = { client: unknown; fn: (event: unknown) => void }; const clientEvents = new Map>(); const clientInstances: Array<{ cfg?: { type?: string; url?: string }; // The options the mount effect built for this server. Captured so a test // can assert what was actually handed to the client — `defaultMetadata` // and the `environment.fetch` proxy wiring (#2067), both of which are // invisible to a render-only assertion. opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } }; }> = []; // What the mocked `createProxyFetch` returns. `undefined` is the real // behavior with no proxy env var set; a test points this at a sentinel to // exercise the proxy-configured branch (#2067). const proxyFetchState: { current: unknown } = { current: undefined }; const fireClientEvent = (event: string, detail?: unknown) => { clientEvents.get(event)?.forEach((e) => e.fn({ detail })); }; const fireClientEventFor = ( client: unknown, event: string, detail?: unknown, ) => { clientEvents.get(event)?.forEach((e) => { if (e.client === client) e.fn({ detail }); }); }; // Optional per-test override for runRunnerInteractiveOAuth. Left null, the real // runner runs (existing tests drive it via the captured callback opts); set it // to return a specific { kind } to deterministically exercise the result // branches without steering the real callback flow. const runner: { override: null | ((opts: unknown) => Promise); } = { override: null }; class FakeManager { destroy = vi.fn(); } class FakeClient { cfg: { type?: string; url?: string } | undefined; opts: | { defaultMetadata?: unknown; environment?: { fetch?: unknown } } | undefined; constructor( config?: { type?: string; url?: string }, opts?: { defaultMetadata?: unknown; environment?: { fetch?: unknown } }, ) { this.cfg = config; this.opts = opts; clientInstances.push(this); } // 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", ); authenticate = (...a: Parameters) => clientSpies.authenticate(...a); clearOAuthTokens = ( ...a: Parameters ) => clientSpies.clearOAuthTokens(...a); completeOAuthFlow = ( ...a: Parameters ) => clientSpies.completeOAuthFlow(...a); getOAuthState = (...a: Parameters) => clientSpies.getOAuthState(...a); callTool = (...a: Parameters) => clientSpies.callTool(...a); checkAuthChallengeSatisfied = ( ...a: Parameters ) => clientSpies.checkAuthChallengeSatisfied(...a); handleAuthChallenge = ( ...a: Parameters ) => clientSpies.handleAuthChallenge(...a); readResource = vi.fn(async () => ({ result: { contents: [{ uri: "file://x", text: "hello" }] }, })); addEventListener = vi.fn((event: string, fn: (event: unknown) => void) => { if (!clientEvents.has(event)) clientEvents.set(event, new Set()); clientEvents.get(event)!.add({ client: this, fn }); }); removeEventListener = vi.fn( (event: string, fn: (event: unknown) => void) => { const set = clientEvents.get(event); if (!set) return; for (const entry of set) { if (entry.fn === fn) { set.delete(entry); break; } } }, ); // Reject so the unmount cleanup's `.catch(() => {})` arrow is exercised. disconnect = vi.fn().mockRejectedValue(new Error("cleanup disconnect")); } return { ctrl, proxyFetchState, connect, disconnect, openUrl, clientSpies, cb, createOAuthCallbackServer, callbackStart, callbackStop, clientEvents, clientInstances, fireClientEvent, fireClientEventFor, runner, 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(), // Defaults to undefined — "no proxy configured", which is what the real one // returns with no proxy env var set, so `environment.fetch` stays unset and // the client falls back to the built-in fetch, exactly as before #2067. A // test can point `h.proxyFetchState.current` at a sentinel to exercise the // other branch. createProxyFetch: vi.fn(() => h.proxyFetchState.current), })); 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", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, CallbackNavigation: class {}, MutableRedirectUrlProvider: class { redirectUrl = ""; }, }; }); vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createOAuthCallbackServer: h.createOAuthCallbackServer, NodeOAuthStorage: class {}, runRunnerInteractiveOAuth: (opts: unknown) => h.runner.override ? h.runner.override(opts) : actual.runRunnerInteractiveOAuth( opts as Parameters[0], ), }; }); vi.mock("../src/utils/openUrl.js", () => ({ openUrl: h.openUrl, })); import App from "../src/App.js"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; import type { TuiServer } from "../src/tui-servers.js"; import { AuthRecoveryRequiredError, EMA_STEP_UP_PENDING_URL, } from "@inspector/core/auth/challenge.js"; import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; const tick = () => new Promise((r) => setTimeout(r, 25)); const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" }; const emptyClientConfig = {}; 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, }; } function oneEmaHttp(): Record { return { ema: { config: { type: "streamable-http", url: "http://localhost:8080/mcp" }, settings: { requestTimeout: 0, metadata: {}, headers: [], env: [], roots: [], maxFetchRequests: 1000, taskTtl: 0, connectionTimeout: 0, oauthClientId: "client_ema_test", oauthScopes: "tools:read", enterpriseManaged: true, }, } as never, }; } // Two OAuth-capable http servers (first auto-selected). Drives the per-server // auth-event guards (events from the non-selected server return early) and the // "a step-up is already pending for another server" branch. function twoHttp(): Record { return { web: { config: { type: "streamable-http", url: "http://a" } } as never, api: { config: { type: "streamable-http", url: "http://b" } } 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, // Object-shaped with a nested value (#1910) — the shape the TUI now // forwards verbatim. As a pair array this would have reached the wire // as numeric `_meta` keys. metadata: { team: "alpha", trace: { id: "abc", hops: [1, 2] } }, 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(); // The mount commit's effect flush can still be queued behind this tick, and // this write is the one that absorbs the dropped first keypress. await settleInputHandlers(); 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) { // Same hazard `waitUntil` guards against, at the other end: a caller can // reach here on a turn that still has React's passive-effect flush queued // (e.g. straight after a plain `tick`, or after an earlier key committed a // render), so settle before every write rather than only after a poll. await settleInputHandlers(); 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. * * The default budget (POLL_TRIES × 25ms tick) is generous on purpose: a poll * exits the instant the predicate is true, so a high ceiling never slows a * passing assertion — it only widens the margin for the slow path. Flows with * an extra async hop (e.g. the step-up OAuth runner before the success frame) * plus React commits can exceed a tight budget under CI load with v8 coverage, * which is what made the step-up frame assertions intermittently time out. */ const POLL_TRIES = 100; /** * One check-phase turn, queued BEHIND React's already-scheduled passive-effect * flush. A frame observed by a poll predicate is written during React's * COMMIT, but ink re-arms its useInput listeners in the passive-effect flush * React schedules (via setImmediate in Node) during that same commit. Node's * event loop runs the timers phase before the check phase, so a 25ms poll * tick can observe the new frame and let the test write the next keypress * BEFORE that flush has run — the key is then dispatched to the previous * commit's stale useInput closures (where e.g. pendingStepUp is still null) * and silently swallowed (#1942). Yielding one setImmediate turn after the * predicate passes sequences the next stdin write after the flush (FIFO * within the check queue), so "frame visible" once again implies "input * handlers armed". */ const settleInputHandlers = () => new Promise((resolve) => setImmediate(resolve)); async function waitUntil(predicate: () => boolean, tries = POLL_TRIES) { for (let i = 0; i < tries; i++) { if (predicate()) { await settleInputHandlers(); 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 = POLL_TRIES, ) { 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.clientEvents.clear(); h.clientInstances.length = 0; h.proxyFetchState.current = undefined; h.runner.override = null; h.clientSpies.authenticate.mockReset(); h.clientSpies.authenticate.mockResolvedValue( new URL("https://auth.example/start"), ); h.clientSpies.clearOAuthTokens.mockReset(); h.clientSpies.completeOAuthFlow.mockReset(); h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined); h.clientSpies.getOAuthState.mockReset(); h.clientSpies.getOAuthState.mockResolvedValue(undefined); h.clientSpies.callTool.mockReset(); h.clientSpies.checkAuthChallengeSatisfied.mockReset(); h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.clientSpies.handleAuthChallenge.mockReset(); h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" }); }); afterEach(() => { while (mounted.length) mounted.pop()?.unmount(); }); // Pins the synchronization contract the OAuth step-up assertions depend on // (#1942). The `setImmediate` sentinel below stands in for React's pending // passive-effect flush — the turn where ink re-arms `useInput`. If `waitUntil` // ever returns without yielding a check-phase turn, the sentinel has not run // and this fails, instead of the regression resurfacing as a differently-named // flaky OAuth test under coverage instrumentation. describe("test helpers", () => { it("waitUntil settles input handlers before resolving", async () => { let flushed = false; setImmediate(() => { flushed = true; }); await waitUntil(() => true); expect(flushed).toBe(true); }); }); 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("surfaces a disconnect failure instead of floating the rejection", async () => { // 'd' is a key handler, so it cannot await handleDisconnect — the handler // has to own the failure itself or it escapes as an unhandled rejection // and fails the whole run from somewhere else (#1959). // The banner is deliberately independent of connection status: a rejected // disconnect leaves the status "connected", so anything gated on // `status === "error"` would never be seen. h.ctrl.status = "connected"; h.disconnect.mockRejectedValue(new Error("discfail")); const r = await mount(oneStdio()); r.stdin.write("d"); await expectFrame(r, "Disconnect failed: discfail"); }); it("owns a non-Error disconnect rejection too", async () => { // The catch stringifies a non-Error rejection rather than reading // `.message` off it; a throw here would escape the same way. h.ctrl.status = "connected"; h.disconnect.mockRejectedValue("plainstring"); const r = await mount(oneStdio()); r.stdin.write("d"); await expectFrame(r, "Disconnect failed: plainstring"); }); it("clears a disconnect failure once a retry succeeds", async () => { // A stale banner would keep reporting a failure the user has since fixed. h.ctrl.status = "connected"; h.disconnect.mockRejectedValueOnce(new Error("discfail")); const r = await mount(oneStdio()); r.stdin.write("d"); await expectFrame(r, "Disconnect failed: discfail"); r.stdin.write("d"); await waitUntil(() => !(r.lastFrame() ?? "").includes("Disconnect failed")); expect(r.lastFrame() ?? "").not.toContain("Disconnect failed"); }); it("drops a disconnect rejection that lands after the user switched servers", async () => { // The stale attempt is the one that usually rejects, so without an // attempt token server alpha's failure would surface in beta's header. h.ctrl.status = "connected"; let rejectDisconnect: (err: Error) => void = () => {}; h.disconnect.mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectDisconnect = reject; }), ); const r = await mount(stdioServer()); r.stdin.write("d"); // alpha's disconnect starts, and hangs await tick(); await press(r, [DOWN]); // switch to beta await expectFrame(r, "beta"); rejectDisconnect(new Error("stale-alpha-failure")); // Give the rejection every chance to be published before asserting it // wasn't — a bare tick would pass even with the guard removed. await waitUntil(() => (r.lastFrame() ?? "").includes("stale-alpha-failure"), ); expect(r.lastFrame() ?? "").not.toContain("stale-alpha-failure"); }); it("drops a stale disconnect rejection across an A → B → A round trip", async () => { // The server-name check alone passes here: by the time the rejection // lands, alpha is selected again. Only retiring the attempt token on // every switch catches it. h.ctrl.status = "connected"; let rejectDisconnect: (err: Error) => void = () => {}; h.disconnect.mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectDisconnect = reject; }), ); const r = await mount(stdioServer()); r.stdin.write("d"); // alpha's disconnect starts, and hangs await tick(); await press(r, [DOWN]); // alpha -> beta await expectFrame(r, "b.js"); await press(r, [UP]); // beta -> alpha again await expectFrame(r, "s.js"); rejectDisconnect(new Error("round-trip-failure")); await waitUntil(() => (r.lastFrame() ?? "").includes("round-trip-failure")); expect(r.lastFrame() ?? "").not.toContain("round-trip-failure"); }); 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 via the accelerator for an OAuth-capable server", async () => { h.ctrl.serverType = "streamable-http"; const r = await mount(httpServer()); await press(r, ["a"]); await expectFrame(r, "OAuth"); }); 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 error status when an http server has a 401 response logged", async () => { h.ctrl.status = "error"; h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }]; const r = await mount(oneHttp()); await expectFrame(r, "error"); await expectFrame(r, "Network (1)"); }); 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, ["m", 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, ["m", 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, ["m", 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, ["p", 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, ["p", 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, ["p", 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, ["n", 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 Network tab and opens full request details", async () => { h.ctrl.status = "connected"; h.ctrl.fetchRequests = [fullRequest]; const r = await mount(oneHttp()); await press(r, ["n", 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, ["n", TAB, TAB, "+"]); await expectFrame(r, "Error: boom"); }); it("renders the Console tab for a stdio server", async () => { h.ctrl.status = "connected"; h.ctrl.stderrLogs = [stderrLog]; const r = await mount(oneStdio()); await press(r, ["o"]); const f = r.lastFrame() ?? ""; expect(f).toContain("Console (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, ["p"]); await press(r, [TAB, TAB, TAB, TAB]); // forward through messages focusOrder await press(r, [STAB, STAB]); // reverse await expectFrame(r, "Protocol"); }); 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, ["n"]); await press(r, [TAB, TAB, TAB, TAB]); await press(r, [STAB, STAB]); await expectFrame(r, "Network"); }); it("switches away from the Auth tab when selecting a non-OAuth server", async () => { const r = await mount(httpThenStdio()); await press(r, ["a", STAB, STAB, DOWN]); await waitUntil(() => (r.lastFrame() ?? "").includes("Type: stdio")); await expectFrame(r, "Server Configuration"); expect(r.lastFrame() ?? "").not.toContain("No OAuth information yet"); }); it("switches away from the Console tab when selecting a non-stdio server", async () => { const r = await mount(stdioThenHttp()); await press(r, ["o"]); // Console tab (stdio) await press(r, [STAB]); // tabs -> serverList await press(r, [DOWN]); // select the http server -> effect leaves Console 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"); // Not just "it mounted": the saved metadata must reach the client as the // same object, nesting intact (#1910). const web = h.clientInstances.find((c) => c.cfg?.url === "http://x"); expect(web?.opts?.defaultMetadata).toEqual({ team: "alpha", trace: { id: "abc", hops: [1, 2] }, }); }); 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)", () => { const unauthorized = Object.assign(new Error("request failed (401)"), { status: 401, }); it("runs OAuth on connect when the server returns 401", async () => { h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); h.clientSpies.authenticate.mockResolvedValue(undefined); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.callbackStart.mock.calls.length > 0); expect(h.disconnect).toHaveBeenCalled(); expect(h.clientSpies.authenticate).toHaveBeenCalled(); expect(h.connect).toHaveBeenCalledTimes(2); }); it("completes OAuth when the callback fires during a 401 connect", async () => { h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); await h.cb.opts!.onCallback({ code: "abc", iss: "https://as.example" }); await tick(); expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith( "abc", "https://as.example", ); }); it("reports an OAuth callback error during a 401 connect", async () => { h.connect.mockRejectedValueOnce(unauthorized); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); h.cb.opts!.onError({ error_description: "denied" }); await expectFrame(r, "denied"); }); it("clears OAuth state from the Auth tab", async () => { const r = await mount(oneHttp()); await press(r, ["a", "s"]); await tick(); expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled(); }); it("reports callback completion failure during a 401 connect", async () => { h.connect.mockRejectedValueOnce(unauthorized); h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("qcfail")); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); await h.cb.opts!.onCallback({ code: "x" }); await expectFrame(r, "qcfail"); }); it("stringifies a non-Error authenticate rejection on 401 connect", async () => { h.connect.mockRejectedValueOnce(unauthorized); h.clientSpies.authenticate.mockRejectedValue("plainstring"); const r = await mount(oneHttp()); await press(r, ["c"]); await expectFrame(r, "plainstring"); }); it("uses the default OAuth error label when the callback error is empty", async () => { h.connect.mockRejectedValueOnce(unauthorized); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); h.cb.opts!.onError({}); await expectFrame(r, "OAuth error"); }); it("falls back to params.error when the callback has no description", async () => { h.connect.mockRejectedValueOnce(unauthorized); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); h.cb.opts!.onError({ error: "oauth-error-code" }); await expectFrame(r, "oauth-error-code"); }); it("wraps a non-Error callback completion failure into an Error", async () => { h.connect.mockRejectedValueOnce(unauthorized); h.clientSpies.completeOAuthFlow.mockRejectedValue("cb-string"); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); await h.cb.opts!.onCallback({ code: "x" }); await expectFrame(r, "cb-string"); }); it("shows EMA step-up confirmation on tool auth recovery instead of auto OAuth", async () => { const challenge = { reason: "insufficient_scope" as const, requiredScopes: ["env:read"], authorizationScopes: ["tools:read", "env:read"], context: { toolName: "get-env" }, }; h.clientSpies.callTool.mockRejectedValue( new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, { emaStepUpConfirm: true, }), ); h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.ctrl.status = "connected"; h.ctrl.tools = [sampleTool]; const r = await mount(oneEmaHttp()); await press(r, ["t", TAB, ENTER]); await expectFrame(r, "MOCK_FORM"); await press(r, [ENTER]); await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0); await expectFrame(r, "organization before it can continue"); const frame = r.lastFrame() ?? ""; expect(frame).toMatch(/organization|get-env/i); expect(frame).not.toMatch(/opens browser/i); expect(frame).not.toMatch(/OAuth: authenticating/i); expect(h.callbackStart).not.toHaveBeenCalled(); }); it("runs confirmed EMA step-up via handleAuthChallenge when user authorizes", async () => { const challenge = { reason: "insufficient_scope" as const, requiredScopes: ["env:read"], authorizationScopes: ["tools:read", "env:read"], context: { toolName: "get-env" }, }; h.clientSpies.callTool.mockRejectedValue( new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, { emaStepUpConfirm: true, }), ); h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" }); h.ctrl.status = "connected"; h.ctrl.tools = [sampleTool]; const r = await mount(oneEmaHttp()); await press(r, ["t", TAB, ENTER]); await expectFrame(r, "MOCK_FORM"); await press(r, [ENTER]); await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0); await expectFrame(r, "organization before it can continue"); await press(r, ["a"]); await waitUntil( () => h.clientSpies.handleAuthChallenge.mock.calls.length > 0, ); expect(h.clientSpies.handleAuthChallenge).toHaveBeenCalledWith(challenge, { confirmedStepUp: true, }); expect(h.callbackStart).not.toHaveBeenCalled(); await expectFrame(r, "Step-up authorization succeeded"); }); }); describe("App (mid-session auth lifecycle events)", () => { it("shows the ambient-refresh message then clears it on recovery", async () => { const r = await mount(oneHttp()); await press(r, ["a"]); // Auth tab so oauthMessage is visible h.fireClientEvent("authChallengeAmbient"); await expectFrame(r, "Refreshing authorization"); h.fireClientEvent("authChallengeRecovered"); await waitUntil( () => !(r.lastFrame() ?? "").includes("Refreshing authorization"), ); expect(r.lastFrame() ?? "").not.toContain("Refreshing authorization"); }); it("surfaces an oauthError event for both Error and non-Error payloads", async () => { const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("oauthError", { error: new Error("oauth boom") }); await expectFrame(r, "oauth boom"); h.fireClientEvent("oauthError", { error: "plain oauth string" }); await expectFrame(r, "plain oauth string"); }); it("clears OAuth tokens and disconnects when connected", async () => { h.ctrl.status = "connected"; const r = await mount(oneHttp()); await press(r, ["a", "s"]); await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0); expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled(); expect(h.disconnect).toHaveBeenCalled(); }); const stepUpChallenge = { reason: "insufficient_scope" as const, requiredScopes: ["env:read"], authorizationScopes: ["tools:read", "env:read"], context: { toolName: "get-env" }, }; it("presents a standard step-up on an interactive auth-challenge event", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: new URL("https://as.example/authorize"), challenge: stepUpChallenge, }); await expectFrame(r, "needs additional OAuth scopes"); }); it("skips step-up when the challenge is already satisfied (interactive event)", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: new URL("https://as.example/authorize"), challenge: stepUpChallenge, }); await expectFrame(r, "Authorization updated"); }); it("auto-runs OAuth on an interactive reauth event (no step-up confirm)", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: new URL("https://as.example/authorize"), challenge: { reason: "unauthorized" as const }, }); await waitUntil(() => h.callbackStart.mock.calls.length > 0); expect(h.callbackStart).toHaveBeenCalled(); }); it("routes a connect AuthRecoveryRequiredError into recovery", async () => { h.connect .mockRejectedValueOnce( new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { reason: "unauthorized", }), ) .mockResolvedValue(undefined); h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.callbackStart.mock.calls.length > 0); expect(h.callbackStart).toHaveBeenCalled(); }); it("surfaces an EMA-client-not-configured error on connect", async () => { h.connect.mockRejectedValue( new EmaClientNotConfiguredError("not_configured"), ); const r = await mount(oneEmaHttp()); await press(r, ["a", "c"]); await waitUntil(() => (r.lastFrame() ?? "").length > 0); await expectFrame(r, "enterprise"); }); }); describe("App (step-up authorize outcomes)", () => { const challenge = { reason: "insufficient_scope" as const, requiredScopes: ["env:read"], authorizationScopes: ["tools:read", "env:read"], context: { toolName: "get-env" }, }; async function presentEmaStepUp() { h.clientSpies.callTool.mockRejectedValue( new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, { emaStepUpConfirm: true, }), ); h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.ctrl.status = "connected"; h.ctrl.tools = [sampleTool]; const r = await mount(oneEmaHttp()); await press(r, ["t", TAB, ENTER]); await expectFrame(r, "MOCK_FORM"); await press(r, [ENTER]); await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0); await expectFrame(r, "organization before it can continue"); return r; } it("reports a failed EMA step-up outcome", async () => { const r = await presentEmaStepUp(); h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "failed", error: new Error("mint failed"), }); await press(r, ["a"]); await expectFrame(r, "mint failed"); }); it("runs interactive OAuth when EMA step-up returns interactive", async () => { const r = await presentEmaStepUp(); h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "interactive", challenge, authorizationUrl: new URL("https://as.example/authorize"), }); await press(r, ["a"]); await waitUntil(() => h.callbackStart.mock.calls.length > 0); expect(h.callbackStart).toHaveBeenCalled(); }); it("surfaces an error when EMA handleAuthChallenge throws", async () => { const r = await presentEmaStepUp(); h.clientSpies.handleAuthChallenge.mockRejectedValue( new Error("challenge boom"), ); await press(r, ["a"]); await expectFrame(r, "challenge boom"); }); it("cancels a pending step-up with 'c'", async () => { const r = await presentEmaStepUp(); await press(r, ["c"]); await expectFrame(r, "Authorization cancelled"); }); it("completes an EMA interactive step-up when OAuth succeeds", async () => { const r = await presentEmaStepUp(); h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "interactive", challenge, authorizationUrl: new URL("https://as.example/authorize"), }); h.runner.override = async () => ({ kind: "success" }); await press(r, ["a"]); await expectFrame(r, "Step-up authorization succeeded"); }); it("completes an EMA interactive step-up when OAuth returns already_authorized", async () => { const r = await presentEmaStepUp(); h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "interactive", challenge, authorizationUrl: new URL("https://as.example/authorize"), }); h.runner.override = async () => ({ kind: "already_authorized" }); await press(r, ["a"]); await expectFrame(r, "Step-up authorization succeeded"); }); }); describe("App (OAuth result branches)", () => { const unauthorized = Object.assign(new Error("request failed (401)"), { status: 401, }); const stepUpChallenge = { reason: "insufficient_scope" as const, requiredScopes: ["env:read"], authorizationScopes: ["tools:read", "env:read"], context: { toolName: "get-env" }, }; const authUrl = () => new URL("https://as.example/authorize"); it("re-connects after OAuth returns already_authorized on a 401", async () => { h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); h.runner.override = async () => ({ kind: "already_authorized" }); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.connect.mock.calls.length >= 2); expect(h.connect).toHaveBeenCalledTimes(2); }); it("handles an unsupported OAuth result on a 401 without reconnecting", async () => { h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); h.runner.override = async () => ({ kind: "failed" }); const r = await mount(oneHttp()); await press(r, ["c"]); await tick(); await tick(); expect(h.connect).toHaveBeenCalledTimes(1); }); it("routes an AuthRecoveryRequiredError thrown during 401 OAuth to recovery", async () => { h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); h.runner.override = async () => { throw new AuthRecoveryRequiredError(authUrl(), { reason: "unauthorized", }); }; h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true); const r = await mount(oneHttp()); await press(r, ["c"]); await waitUntil(() => h.disconnect.mock.calls.length > 0); await press(r, ["a"]); // view the Auth tab where oauthMessage renders await expectFrame(r, "Authorization updated"); }); it("surfaces an EMA-not-configured error thrown during 401 OAuth", async () => { h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); h.runner.override = async () => { throw new EmaClientNotConfiguredError("disabled"); }; const r = await mount(oneHttp()); await press(r, ["a", "c"]); await expectFrame(r, "enterprise"); }); it("completes a standard step-up authorize when OAuth succeeds", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.runner.override = async () => ({ kind: "success" }); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: authUrl(), challenge: stepUpChallenge, }); await expectFrame(r, "needs additional OAuth scopes"); await press(r, ["a"]); await expectFrame(r, "Step-up authorization succeeded"); }); it("completes a standard step-up authorize when OAuth returns already_authorized", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.runner.override = async () => ({ kind: "already_authorized" }); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: authUrl(), challenge: stepUpChallenge, }); await expectFrame(r, "needs additional OAuth scopes"); await press(r, ["a"]); await expectFrame(r, "Step-up authorization succeeded"); }); it("shows insufficient-scope message when step-up OAuth stays insufficient", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.runner.override = async () => ({ kind: "insufficient_scope", challenge: stepUpChallenge, }); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: authUrl(), challenge: stepUpChallenge, }); await expectFrame(r, "needs additional OAuth scopes"); await press(r, ["a"]); await expectFrame(r, "were not granted"); }); it("skips reauth when already satisfied (interactive event)", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: authUrl(), challenge: { reason: "unauthorized" }, }); await expectFrame(r, "Authorization updated"); }); it("completes reauth via OAuth on an interactive event", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.runner.override = async () => ({ kind: "success" }); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: authUrl(), challenge: { reason: "unauthorized" }, }); await expectFrame(r, "Authorization updated. Retry your action"); }); it("completes reauth when OAuth returns already_authorized", async () => { h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); h.runner.override = async () => ({ kind: "already_authorized" }); const r = await mount(oneHttp()); await press(r, ["a"]); h.fireClientEvent("authChallengeInteractive", { authorizationUrl: authUrl(), challenge: { reason: "unauthorized" }, }); await expectFrame(r, "Authorization updated. Retry your action"); }); it("installs the proxy fetch as environment.fetch when a proxy is configured", async () => { // The bottom-of-stack wiring for #2067. It is one line in App.tsx and it is // what makes InspectorClient's own wrappers compose OVER the proxy instead // of discarding it — delete it and TUI proxy support silently disappears // while every other test stays green. const sentinel: typeof fetch = async () => new Response(""); h.proxyFetchState.current = sentinel; await mount(oneHttp()); expect(h.clientInstances.length).toBeGreaterThan(0); expect(h.clientInstances[0]!.opts?.environment?.fetch).toBe(sentinel); }); it("leaves environment.fetch unset when no proxy is configured", async () => { // The default path must stay on the built-in fetch — a wrapper installed // unconditionally would put every TUI user behind an undici fetch. h.proxyFetchState.current = undefined; await mount(oneHttp()); expect(h.clientInstances.length).toBeGreaterThan(0); expect(h.clientInstances[0]!.opts?.environment?.fetch).toBeUndefined(); }); it("ignores auth lifecycle events from a non-selected server", async () => { const r = await mount(twoHttp()); // web is selected; api is not await press(r, ["a"]); const api = h.clientInstances.find((c) => c.cfg?.url === "http://b"); const web = h.clientInstances.find((c) => c.cfg?.url === "http://a"); // Firing ONLY the non-selected server's handlers must hit the // `selectedServerRef.current !== serverName` guard on each listener and set // no message — if any guard were removed, a negative assertion would fail. h.fireClientEventFor(api, "authChallengeAmbient"); h.fireClientEventFor(api, "authChallengeRecovered"); h.fireClientEventFor(api, "oauthError", { error: new Error("api-only error"), }); await tick(); expect(r.lastFrame() ?? "").not.toContain("Refreshing authorization"); expect(r.lastFrame() ?? "").not.toContain("api-only error"); // The selected server's handlers do act (guard false): ambient refreshes, // then an error surfaces. h.fireClientEventFor(web, "authChallengeAmbient"); await expectFrame(r, "Refreshing authorization"); h.fireClientEventFor(web, "oauthError", { error: new Error("web error") }); await expectFrame(r, "web error"); }); });