inspector/clients/cli/__tests__/emit-result.test.ts
Bob Dickinson 33e88eac9f
CLI: catalog listing, auth flags, and OAuth browser UX (#1782)
* feat(cli): catalog listing, auth flags, and OAuth browser UX

Add servers/list and servers/show, --relogin / --stored-auth-only, browser
OAuth navigation with OSC 8 links, and extract method handlers under
clients/cli/src/handlers. Tracks #1781.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address #1782 review — relogin clear, stored-auth-only, docs

Clear both raw and normalized OAuth keys on --relogin; check the shared
store before bailing under --stored-auth-only mid-session; restore the
tests README link target; document servers/show redaction limits; keep
mcpi groundwork with TODO(#1432) markers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): gate OAuth browser open and harden #1782 review follow-ups

Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED,
move --relogin clear after validation, skip keychain on servers/list, and cover
secret redaction after rehydrate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): silence disarmed OAuth URLs and round-3 review nits

Suppress authorize URL print when navigation is disarmed or
--stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth
headers; keep servers/show unit tests off the OS keychain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): cover withArmedAutoOpen for the ≥90 branch gate

CI failed on cliOAuth.ts branch coverage after the auto-open arming
helper landed; exercise the armed path through runCliInteractiveOAuth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): mid-RPC unauthorized recovery and clear round-4 nits

Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let
MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the
in-process runner, reject --relogin on catalog methods, restore the
__tests__ README inventory, and narrow OAuth helpers to structural client types.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits

Reconnect before retrying after unauthorized recovery, guard non-OAuth
configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles,
and reject --relogin with stored-auth short-circuits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits

Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true;
document --stored-auth-only for CI; redact settings.metadata; reject
--relogin for stdio; clarify AuthRecoveryRequired reconnect comments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): admit interactive OAuth when stdin is a TTY (round-7)

Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/
TTY into CliOAuthConnectOptions; rename authRequiredFailure; document
MCP_AUTO_OPEN_ENABLED=true as non-TTY admit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on step-up without stdin (round-8)

Decline [y/N] on readline EOF/close, require a stdin TTY for the default
step-up confirmer (force-open admit is not enough), document stderr-only
browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): allow piped step-up answers; bound silent pipes (round-9)

Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and
add a short non-TTY timeout so an open pipe that never writes cannot hang.
Clarify isTTY as admit-only; extract the readline mock factory.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): keep partial piped step-up answers (round-10)

Capture readline `line` before close so `printf y` without a newline is
not treated as decline; timeout is auth_required with a distinct message;
document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): tighten step-up timeout messaging and TTY test (round-11)

Make the TTY no-timeout assertion use a settled flag (the Promise.race
check was vacuous), say "timed out" instead of "declined" on the pipe
bound, and document that piped answers need a newline or stdin close.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:55:25 -04:00

167 lines
5.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { emitResult, collectAppInfo } from "../src/cli.js";
import { CliExitCodeError } from "../src/error-handler.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
/**
* Direct unit coverage for the two output-side branches that the end-to-end
* CLI tests can't reach with the default stdio fixture: a tool that returns
* `isError:true` (exit 5), and a UI-resource read that fails during an
* `--app-info` probe (`resourceError`). Both functions are exported from
* `cli.ts` purely for this test, mirroring the `withConnectTimeout` pattern.
*/
describe("emitResult", () => {
let stdout: string;
let originalWrite: typeof process.stdout.write;
beforeEach(() => {
stdout = "";
originalWrite = process.stdout.write;
process.stdout.write = ((chunk: unknown, ...rest: unknown[]): boolean => {
stdout += typeof chunk === "string" ? chunk : String(chunk);
const cb = rest.find((r) => typeof r === "function") as
| (() => void)
| undefined;
cb?.();
return true;
}) as typeof process.stdout.write;
});
afterEach(() => {
process.stdout.write = originalWrite;
});
it("uses a default appInfo shell when --app-info has no collected info", async () => {
const promise = emitResult(undefined as never, undefined, {
appInfo: true,
toolName: "missing-app",
format: "text",
});
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
await promise.catch((e: CliExitCodeError) => {
expect(e.exitCode).toBe(2);
});
expect(JSON.parse(stdout.trim())).toMatchObject({
hasApp: false,
toolName: "missing-app",
});
});
it("throws TOOL_ERROR (code:tool_is_error) when the result has isError:true", async () => {
const promise = emitResult({ isError: true }, undefined, {
toolName: "boom",
});
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
await promise.catch((e: CliExitCodeError) => {
expect(e.exitCode).toBe(5);
expect(e.envelope?.code).toBe("tool_is_error");
});
// The payload is still printed before the throw.
expect(stdout).toContain("isError");
});
it("does not throw for a successful result (text mode)", async () => {
await expect(
emitResult({ ok: true }, undefined, { format: "text" }),
).resolves.toBeUndefined();
expect(JSON.parse(stdout.trim())).toEqual({ ok: true });
});
it("wraps result + appInfo under a json envelope", async () => {
await emitResult(
{ value: 1 },
{ hasApp: true, toolName: "t", resourceUri: "ui://x" },
{ format: "json" },
);
expect(JSON.parse(stdout.trim())).toEqual({
result: { value: 1 },
appInfo: { hasApp: true, toolName: "t", resourceUri: "ui://x" },
});
});
});
describe("collectAppInfo", () => {
it("returns base info for a non-App tool without reading a resource", async () => {
const client = {
readResource: vi.fn(),
} as unknown as Pick<InspectorClient, "readResource">;
const info = await collectAppInfo(
client,
{ name: "plain", inputSchema: { type: "object" as const } },
undefined,
);
expect(info).toEqual({ hasApp: false, toolName: "plain" });
expect(
(client.readResource as ReturnType<typeof vi.fn>).mock.calls.length,
).toBe(0);
});
it("captures resourceError when the UI resource read fails", async () => {
const client = {
readResource: vi.fn().mockRejectedValue(new Error("unreadable")),
} as unknown as Pick<InspectorClient, "readResource">;
const tool = {
name: "app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "ui://app/widget.html" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.hasApp).toBe(true);
expect(info.resourceUri).toBe("ui://app/widget.html");
expect(info.resourceError).toBe("unreadable");
});
it("stringifies a non-Error rejection into resourceError", async () => {
const client = {
readResource: vi.fn().mockRejectedValue("string failure"),
} as unknown as Pick<InspectorClient, "readResource">;
const tool = {
name: "app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "ui://app/widget.html" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.resourceError).toBe("string failure");
});
it("stringifies a non-Error throw from extractAppInfo", async () => {
const extract = await import("@inspector/core/mcp/apps.js");
const spy = vi
.spyOn(extract, "extractAppInfo")
.mockImplementationOnce(() => {
throw "boom-string";
});
try {
const info = await collectAppInfo(
{ readResource: vi.fn() } as never,
{ name: "t", inputSchema: { type: "object" as const } },
undefined,
);
expect(info.resourceError).toBe("boom-string");
} finally {
spy.mockRestore();
}
});
it("folds a malformed _meta.ui.resourceUri into {hasApp:false, resourceError} instead of throwing", async () => {
// extractAppInfo throws when the advertised UI URI is not a `ui://` string;
// collectAppInfo must tolerate that so the tools/list --app-info NDJSON loop
// stays per-tool robust. readResource must never be reached here.
const readResource = vi.fn();
const client = { readResource } as unknown as Pick<
InspectorClient,
"readResource"
>;
const tool = {
name: "bad-app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "http://not-a-ui-uri" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.hasApp).toBe(false);
expect(info.toolName).toBe("bad-app");
expect(typeof info.resourceError).toBe("string");
expect(info.resourceError!.length).toBeGreaterThan(0);
expect(readResource.mock.calls.length).toBe(0);
});
});