inspector/clients/tui/__tests__/App.test.tsx
cliffhall 775107776d fix: address Copilot review round 1 on #2248
Five findings, all real.

**Critical — `verifySkills` selected `contents[0]`.** These bytes are hashed
against the file's advertised digest, so accepting a block the server
labelled something else verifies one file's content against another file's
digest, and can report that as `verified`. A false pass is worse than a
missing check. `contentsFor(result, uri)` now selects by NORMALIZED identity,
which keeps the canonicalized-echo case that motivated the original code
while rejecting an unrelated block, and reports a read error when nothing
answers for the URI. This is what `onReadSkillFile` already did; the two
paths hash bytes against digests and must not disagree about which bytes.

**`skills/get` was dispatched without the extension gate** that `skills/list`
had. Hoisted into `assertSkillsSupported` so the two cannot drift — declaring
the extension commits a server to both. Without it, an undeclared server's
-32601 is indistinguishable to a script from the -32602 a declared server
returns for a URI it does not serve.

**The TUI could strand a user on a hidden Skills tab.** The tab left the bar
when the gate went false but `activeTab` did not, so the pane kept rendering
for a server that never declared the extension. Reset to `info`, following
the Auth precedent — additionally gated on `connected`, because this gate
reads a server declaration and would otherwise fire during a reconnect.

**The TUI keyed a verdict by URI**, so a refresh replacing the manifest under
the same URI left hashes computed for the previous snapshot describing the
new one. Keyed on the entry now, as the web screen already was.

**A failed "Load more" discarded the pages on screen and the retry cursor.**
Both are preserved now, and the success and failure paths share one
staleness-guarded `commit` helper so they cannot drift on which results they
may write — which is how they came to disagree.

Two nits: the six-skill counts. The docs sentence was more wrong than
reported (five awkward skills of six, three outright violations) and is
rewritten rather than renumbered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 18:43:58 -04:00

1907 lines
70 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "./helpers/renderTui";
type RenderResult = ReturnType<typeof render>;
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<string, unknown> | 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[];
skills: unknown[];
skillsExtension: { directoryRead: boolean } | undefined;
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: [],
skills: [],
skillsExtension: undefined as { directoryRead: boolean } | undefined,
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<InspectorClient["authenticate"]>(
async () => new URL("https://auth.example/start"),
),
clearOAuthTokens: vi.fn<InspectorClient["clearOAuthTokens"]>(async () => ({
status: "skipped" as const,
reason: "no_endpoint" as const,
})),
completeOAuthFlow: vi.fn<InspectorClient["completeOAuthFlow"]>(
async () => {},
),
getOAuthState: vi.fn<InspectorClient["getOAuthState"]>(
async () => undefined,
),
callTool: vi.fn<InspectorClient["callTool"]>(),
checkAuthChallengeSatisfied: vi.fn<
InspectorClient["checkAuthChallengeSatisfied"]
>(async () => false),
handleAuthChallenge: vi.fn<InspectorClient["handleAuthChallenge"]>(
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> | 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<string, Set<EventEntry>>();
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<unknown>);
} = { 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",
);
// The Skills tab is gated on a SERVER declaration, so the default here is
// "not declared" — the tab is hidden unless a test opts in by pointing
// `ctrl.skillsExtension` at a declaration.
getSkillsExtension = vi.fn(() => ctrl.skillsExtension);
authenticate = (...a: Parameters<InspectorClient["authenticate"]>) =>
clientSpies.authenticate(...a);
clearOAuthTokens = (
...a: Parameters<InspectorClient["clearOAuthTokens"]>
) => clientSpies.clearOAuthTokens(...a);
completeOAuthFlow = (
...a: Parameters<InspectorClient["completeOAuthFlow"]>
) => clientSpies.completeOAuthFlow(...a);
getOAuthState = (...a: Parameters<InspectorClient["getOAuthState"]>) =>
clientSpies.getOAuthState(...a);
callTool = (...a: Parameters<InspectorClient["callTool"]>) =>
clientSpies.callTool(...a);
checkAuthChallengeSatisfied = (
...a: Parameters<InspectorClient["checkAuthChallengeSatisfied"]>
) => clientSpies.checkAuthChallengeSatisfied(...a);
handleAuthChallenge = (
...a: Parameters<InspectorClient["handleAuthChallenge"]>
) => 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 })),
useManagedSkills: vi.fn(() => ({
skills: ctrl.skills,
pageCount: ctrl.skills.length > 0 ? 1 : 0,
error: null,
})),
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,
ManagedSkillsState: 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/useManagedSkills.js", () => ({
useManagedSkills: h.useManagedSkills,
}));
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<typeof import("@inspector/core/auth/index.js")>();
return {
...actual,
CallbackNavigation: class {},
MutableRedirectUrlProvider: class {
redirectUrl = "";
},
};
});
vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/auth/node/index.js")>();
return {
...actual,
createOAuthCallbackServer: h.createOAuthCallbackServer,
NodeOAuthStorage: class {},
runRunnerInteractiveOAuth: (opts: unknown) =>
h.runner.override
? h.runner.override(opts)
: actual.runRunnerInteractiveOAuth(
opts as Parameters<typeof actual.runRunnerInteractiveOAuth>[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<string, TuiServer> {
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<string, TuiServer> {
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<string, TuiServer> {
return {
alpha: {
config: { type: "stdio", command: "node", args: ["s.js"] },
} as never,
};
}
// Single streamable-http server catalog (auto-selected on mount).
function oneHttp(): Record<string, TuiServer> {
return {
web: { config: { type: "streamable-http", url: "http://x" } } as never,
};
}
/** An HTTP server that opted out of RFC 7009 revocation on clear (#2144). */
function oneHttpNoRevoke(): Record<string, TuiServer> {
return {
web: {
config: { type: "streamable-http", url: "http://x" },
settings: {
requestTimeout: 0,
metadata: {},
headers: [],
env: [],
roots: [],
maxFetchRequests: 1000,
taskTtl: 0,
connectionTimeout: 0,
oauthRevokeOnClear: false,
},
} as never,
};
}
function oneEmaHttp(): Record<string, TuiServer> {
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<string, TuiServer> {
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 <20> drives per-server tab gating + the tab-switch-away effects.
function httpThenStdio(): Record<string, TuiServer> {
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<string, TuiServer> {
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<string, TuiServer> {
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 <20> concurrent
// mounted ink apps share raw-mode stdin handling and interfere with useInput.
const mounted: RenderResult[] = [];
function renderApp(servers: Record<string, TuiServer>) {
const r = render(
<App
mcpServers={servers}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
/>,
);
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<string, TuiServer>) {
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 <20> 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 <20> 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: [],
skills: [],
skillsExtension: undefined as { directoryRead: boolean } | undefined,
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();
// #2144: the clear now reads the returned revocation outcome, so the reset
// default has to be an outcome rather than `undefined`.
h.clientSpies.clearOAuthTokens.mockResolvedValue({
status: "skipped",
reason: "no_endpoint",
});
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("hides the Skills tab until the server declares the extension", async () => {
// A *server*-declared extension (SEP-2640), so unlike the transport-derived
// tabs it is only knowable after connecting — and showing it against a
// server that never declared it would send `skills/list` to a server that
// answers -32601 (#2248).
h.ctrl.status = "connected";
const r = await mount(oneStdio());
await expectFrame(r, "Tools");
expect(r.lastFrame() ?? "").not.toContain("Skills");
});
it("shows the Skills tab, with its count, once the extension is declared", async () => {
h.ctrl.status = "connected";
h.ctrl.skillsExtension = { directoryRead: false };
h.ctrl.skills = [
{
uri: "skill://demo/SKILL.md",
frontmatter: { name: "demo", description: "d" },
resources: [],
},
];
const r = await mount(oneStdio());
await expectFrame(r, "Skills (1)");
});
it("opens the Skills tab with its 'k' accelerator", async () => {
// `k`, not `s` — the accelerator has to appear in the label and stay
// unique; see `tabsConfig.ts`.
h.ctrl.status = "connected";
h.ctrl.skillsExtension = { directoryRead: true };
const r = await mount(oneStdio());
await expectFrame(r, "Skills");
r.stdin.write("k");
await expectFrame(r, "Select a skill to view details");
});
it("leaves the Skills tab when the selected server does not serve it", async () => {
// The tab disappears from the bar when the gate goes false, but `activeTab`
// is independent of the bar — so without this the render branch keeps
// showing the pane for a server that never declared the extension, and the
// user is stranded on content they cannot navigate back to (Copilot).
h.ctrl.status = "connected";
h.ctrl.skillsExtension = { directoryRead: false };
const r = await mount(oneStdio());
await expectFrame(r, "Skills");
r.stdin.write("k");
await expectFrame(r, "Select a skill to view details");
// The server stops declaring it — the shape of switching to one without
// the extension, since the declaration is read off the live client.
h.ctrl.skillsExtension = undefined;
r.rerender(
<App
mcpServers={oneStdio()}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
/>,
);
await tick();
await expectFrame(r, "Server Configuration");
expect(r.lastFrame() ?? "").not.toContain("Select a skill to view details");
});
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<void>((_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<void>((_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 <20> 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(
<App
mcpServers={oneHttp()}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
clientId="cid"
clientSecret="sec"
clientMetadataUrl="http://meta"
/>,
);
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();
});
// #2144 — revocation is on unless the entry opted out, and the opt-out has to
// reach the client, since that is where the RFC 7009 request is made.
it("asks for revocation by default when clearing", async () => {
const r = await mount(oneHttp());
await press(r, ["a", "s"]);
await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0);
expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalledWith({
revoke: true,
});
});
it("forwards the per-server revocation opt-out", async () => {
const r = await mount(oneHttpNoRevoke());
await press(r, ["a", "s"]);
await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0);
expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalledWith({
revoke: false,
});
});
// A failed revocation leaves the grant live at the authorization server, so
// it has to be visible rather than swallowed — the local clear succeeded and
// would otherwise look like the whole operation did.
it("reports a failed revocation without failing the clear", async () => {
h.clientSpies.clearOAuthTokens.mockResolvedValue({
status: "failed",
detail: "unreachable",
});
const r = await mount(oneHttp());
await press(r, ["a", "s"]);
// The detail is what identifies the failure; the tone the message renders
// in is asserted against `AuthTab` directly, where the branch lives.
await expectFrame(r, "unreachable");
});
// The wiring is the contract here, not just `AuthTab`'s own behavior: a
// `void`-ing arrow between them resolves instantly, which makes the pending
// state, the repeat lock and the rejection path all inert while revocation
// is still running. Driven through the real App so the arrow cannot come
// back (#2144).
it("holds the pending state until the clear actually settles", async () => {
let settle: () => void = () => {};
h.clientSpies.clearOAuthTokens.mockImplementation(
() =>
new Promise((resolve) => {
settle = () =>
resolve({ status: "skipped", reason: "no_endpoint" as const });
}),
);
const r = await mount(oneHttp());
await press(r, ["a", "s"]);
await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0);
await tick();
// Still running: the pending line is up and the confirmation is not.
expect(r.lastFrame() ?? "").toContain("Clearing OAuth state");
expect(r.lastFrame() ?? "").not.toContain("OAuth state cleared");
// And the repeat lock is live, so a second press does not start another.
await press(r, ["s"]);
expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalledTimes(1);
settle();
await waitUntil(() =>
(r.lastFrame() ?? "").includes("OAuth state cleared"),
);
});
// The clear has already succeeded when the disconnect runs, so its failure
// must not propagate: `AuthTab` would report "Could not clear OAuth state",
// which is false. It goes to the disconnect error line, where the same
// failure from the `d` key already lands.
it("reports a post-clear disconnect failure as a disconnect failure", async () => {
h.ctrl.status = "connected";
h.disconnect.mockRejectedValue(new Error("disconnect-blew-up"));
const r = await mount(oneHttp());
await press(r, ["a", "s"]);
await expectFrame(r, "disconnect-blew-up");
expect(r.lastFrame() ?? "").not.toContain("Could not clear OAuth state");
});
// A transport can reject with something that is not an `Error`; the message
// line must still be readable rather than "[object Object]".
it("reports a non-Error disconnect failure from a clear", async () => {
h.ctrl.status = "connected";
h.disconnect.mockRejectedValue("plain-disconnect-string");
const r = await mount(oneHttp());
await press(r, ["a", "s"]);
await expectFrame(r, "plain-disconnect-string");
});
// A clear while still CONNECTING tears the attempt down too — the session is
// half-built, and leaving it up with its OAuth state gone is worse than not
// having it.
it("disconnects a connecting session when clearing", async () => {
h.ctrl.status = "connecting";
const r = await mount(oneHttp());
await press(r, ["a", "s"]);
await waitUntil(() => h.disconnect.mock.calls.length > 0);
expect(h.disconnect).toHaveBeenCalled();
});
it("says nothing when there was nothing to revoke", async () => {
const r = await mount(oneHttp());
await press(r, ["a", "s"]);
await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0);
expect(r.lastFrame() ?? "").not.toContain("authorization server failed");
});
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");
});
});