Extends the previous commit's fix rather than changing it. `waitUntil` now settles, but the same window is open wherever a key is written without having just polled: `press()` reaches its `stdin.write` on a turn that may still have React's passive-effect flush queued (straight after a plain `tick`, or after an earlier key committed a render), and `mount()`'s absorbing keypress lands one tick after the mount commit. Both now `await settleInputHandlers()` before writing, so "frame visible" implies "input handlers armed" at every dispatch point, not only after a frame wait. Adds a `test helpers` case that pins the contract: a `setImmediate` sentinel stands in for the pending effect flush and must have run by the time `waitUntil` resolves. Verified it fails when the yield is removed, so a future regression surfaces here instead of as a differently-named flaky OAuth test under coverage instrumentation. Signed-off-by: cliffhall <cliff@futurescale.com>
1549 lines
55 KiB
TypeScript
1549 lines
55 KiB
TypeScript
import React from "react";
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import { render } from "ink-testing-library";
|
||
|
||
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[];
|
||
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<InspectorClient["authenticate"]>(
|
||
async () => new URL("https://auth.example/start"),
|
||
),
|
||
clearOAuthTokens: vi.fn<InspectorClient["clearOAuthTokens"]>(
|
||
async () => {},
|
||
),
|
||
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 } }> = [];
|
||
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;
|
||
constructor(config?: { type?: string; url?: string }) {
|
||
this.cfg = config;
|
||
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<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,
|
||
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(),
|
||
}));
|
||
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<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,
|
||
};
|
||
}
|
||
|
||
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,
|
||
metadata: [
|
||
{ key: "team", value: "alpha" },
|
||
{ key: " ", value: "ignored" },
|
||
],
|
||
oauthClientId: "cid",
|
||
oauthClientSecret: "secret",
|
||
oauthScopes: "read write",
|
||
},
|
||
} as never,
|
||
};
|
||
}
|
||
|
||
// Realistic minimal fixtures for tab content / details modals.
|
||
const sampleTool = {
|
||
name: "alpha",
|
||
description: "Tool desc line1\nline2",
|
||
inputSchema: { type: "object", properties: { x: { type: "string" } } },
|
||
};
|
||
const sampleResource = {
|
||
name: "res1",
|
||
uri: "file://x",
|
||
description: "rdesc",
|
||
mimeType: "text/plain",
|
||
};
|
||
const sampleTemplate = {
|
||
name: "tmpl1",
|
||
uriTemplate: "file://{id}",
|
||
description: "tdesc",
|
||
};
|
||
const promptWithArgs = {
|
||
name: "p1",
|
||
description: "pdesc",
|
||
arguments: [{ name: "arg1", description: "a1" }],
|
||
};
|
||
const promptNoName = { name: "", description: "no name prompt" };
|
||
const reqMessage = {
|
||
id: "m1",
|
||
direction: "request",
|
||
message: { jsonrpc: "2.0", id: 1, method: "tools/list" },
|
||
response: { jsonrpc: "2.0", id: 1, result: {} },
|
||
timestamp: new Date(0),
|
||
duration: 5,
|
||
};
|
||
const notifMessage = {
|
||
id: "m2",
|
||
direction: "notification",
|
||
message: { jsonrpc: "2.0", method: "notifications/message" },
|
||
timestamp: new Date(0),
|
||
};
|
||
const fullRequest = {
|
||
id: "r1",
|
||
method: "POST",
|
||
url: "http://x/mcp",
|
||
category: "transport",
|
||
responseStatus: 200,
|
||
responseStatusText: "OK",
|
||
duration: 12,
|
||
timestamp: new Date(0),
|
||
requestHeaders: { "content-type": "application/json" },
|
||
requestBody: JSON.stringify({ a: 1 }),
|
||
responseHeaders: { "x-h": "v" },
|
||
responseBody: JSON.stringify({ ok: true }),
|
||
};
|
||
const errorRequest = {
|
||
id: "r2",
|
||
method: "GET",
|
||
url: "http://x/auth",
|
||
category: "auth",
|
||
error: "boom",
|
||
timestamp: new Date(0),
|
||
requestHeaders: { accept: "*/*" },
|
||
requestBody: "not json{",
|
||
responseBody: "also not json{",
|
||
};
|
||
const respMessage = {
|
||
id: "m3",
|
||
direction: "response",
|
||
message: { jsonrpc: "2.0", id: 2, result: { ok: true } },
|
||
timestamp: new Date(0),
|
||
};
|
||
const bareRequest = {
|
||
id: "r3",
|
||
method: "GET",
|
||
url: "http://x/idle",
|
||
category: "transport",
|
||
timestamp: new Date(0),
|
||
requestHeaders: {},
|
||
};
|
||
const stderrLog = { timestamp: new Date(0), message: "log line" };
|
||
|
||
// Track rendered instances so each is unmounted after its test <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: [],
|
||
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.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("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("[Z");
|
||
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");
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
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("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");
|
||
});
|
||
});
|