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>
This commit is contained in:
parent
a7b2fdb2ef
commit
33e88eac9f
17 changed files with 4041 additions and 520 deletions
|
|
@ -1,73 +1,65 @@
|
|||
# CLI Tests
|
||||
|
||||
## Running Tests
|
||||
Tests live under `__tests__/` and run via Vitest.
|
||||
|
||||
- Most tests import `runCli()` **in-process** (see `helpers/cli-runner.ts`) so
|
||||
`clients/cli/src` is measured under the coverage gate. Suite-wide
|
||||
`helpers/mock-open-url.ts` (vitest `setupFiles`) mocks `open-url` so an armed
|
||||
interactive OAuth path cannot launch a real browser.
|
||||
- `e2e.test.ts` (and root `scripts/smoke-cli.mjs`) spawn the built binary for
|
||||
shebang / `process.exit` paths — `pretest` builds `test-servers` + the CLI
|
||||
bundle first.
|
||||
|
||||
## Scripts (from `clients/cli/`)
|
||||
|
||||
```bash
|
||||
# Run all tests (pretest builds test-servers + the CLI binary)
|
||||
npm test
|
||||
|
||||
# Run all tests under the per-file coverage gate
|
||||
npm run test:coverage
|
||||
|
||||
# Run in watch mode (useful for test file changes)
|
||||
npm run test:watch
|
||||
|
||||
# Run specific test file
|
||||
npm run test:cli # cli.test.ts
|
||||
npm run test:cli-tools # tools.test.ts
|
||||
npm run test:cli-headers # headers.test.ts
|
||||
npm run test:cli-metadata # metadata.test.ts
|
||||
npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity
|
||||
npm test # pretest build + all tests
|
||||
npm run test:cli # subset: cli.test.ts
|
||||
npm run test:cli-tools # subset: tools.test.ts
|
||||
npm run test:cli-headers # subset: headers.test.ts
|
||||
npm run test:cli-metadata # subset: metadata.test.ts
|
||||
npm run test:coverage # build + ≥90 per-file coverage gate
|
||||
npm run validate # format:check && lint && typecheck && test
|
||||
```
|
||||
|
||||
## How the CLI is exercised
|
||||
## Test files
|
||||
|
||||
Most tests run the CLI **in-process**: `helpers/cli-runner.ts` imports `runCli()`
|
||||
from `../src/cli.ts` and invokes it directly, capturing `process.stdout`/`stderr`
|
||||
and mapping thrown errors to an exit code. This is what lets vitest's v8 coverage
|
||||
instrument `clients/cli/src` (#1484). Because `commander` uses `.exitOverride()`,
|
||||
a parse/usage error throws instead of calling `process.exit` and tearing down the
|
||||
worker.
|
||||
|
||||
`e2e.test.ts` is the deliberately thin **out-of-process** layer: it spawns the
|
||||
**built** `build/index.js` as a real subprocess to cover the shebang, the
|
||||
`index.ts` `isMain` bootstrap, and the actual `process.exit` codes — the only
|
||||
parts the in-process runner can't reach. (`scripts/smoke-cli.mjs` at the repo
|
||||
root provides a further end-to-end check of the binary.)
|
||||
|
||||
## Test Files
|
||||
|
||||
- `cli.test.ts` - Basic CLI functionality: CLI mode, environment variables, config files, resources, prompts, logging, transport types
|
||||
- `tools.test.ts` - Tool-related tests: Tool discovery, JSON argument parsing, error handling, prompts
|
||||
- `headers.test.ts` - Header parsing and validation
|
||||
- `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation
|
||||
- `methods.test.ts` - Method/option-validation paths not covered elsewhere (resource templates, missing/invalid options, the `--` target separator)
|
||||
- `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed
|
||||
- `oauth-runner.test.ts` - OAuth flag wiring (`--client-config`, `--callback-url`, CIMD overrides)
|
||||
- `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry)
|
||||
- `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e)
|
||||
- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth)
|
||||
| File | Focus |
|
||||
| --------------------------------------- | ------------------------------------------- |
|
||||
| `cli.test.ts` | Core connect / method / config matrix |
|
||||
| `tools.test.ts` | `tools/call` argument coercion |
|
||||
| `headers.test.ts` | `--header` merging |
|
||||
| `metadata.test.ts` | `--metadata` / `--tool-metadata` |
|
||||
| `methods.test.ts` | Method allow-list / rejection |
|
||||
| `app-info.test.ts` | `--app-info` probe paths |
|
||||
| `format-json.test.ts` | `--format json` envelopes |
|
||||
| `format-output.test.ts` | Text/json writers |
|
||||
| `emit-result.test.ts` | Result emission helpers |
|
||||
| `method-types.test.ts` | `ONE_SHOT_METHODS` / guards |
|
||||
| `run-method.test.ts` | Handler dispatch against a real test server |
|
||||
| `run-method-mocks.test.ts` | Handler edge cases with mocks |
|
||||
| `servers-list.test.ts` | `servers/list` / `servers/show` + redaction |
|
||||
| `cliOAuth.test.ts` | Connect / mid-RPC OAuth recovery |
|
||||
| `cli-oauth-navigation.test.ts` | OSC 8, arm/disarm, `MCP_AUTO_OPEN_ENABLED` |
|
||||
| `open-url.test.ts` | `open` package wrapper |
|
||||
| `oauth-runner.test.ts` | Runner client-config / CIMD flags |
|
||||
| `oauth-interactive.test.ts` | Loopback callback OAuth (in-process) |
|
||||
| `stored-auth.test.ts` | `--use-stored-auth` / handoff / wait |
|
||||
| `clear-stored-auth-for-relogin.test.ts` | Dual-key `--relogin` clear |
|
||||
| `programmatic-ergonomics.test.ts` | Flag conflicts, timeouts, ergonomics |
|
||||
| `error-handler.test.ts` | Exit codes / error shaping |
|
||||
| `style.test.ts` | ANSI / OSC 8 helpers |
|
||||
| `e2e.test.ts` | Out-of-process binary smoke |
|
||||
| `helpers/assertions.test.ts` | Assertion helpers |
|
||||
|
||||
## Helpers
|
||||
|
||||
The `helpers/` directory contains shared utilities:
|
||||
| Helper | Role |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| `helpers/cli-runner.ts` | In-process `runCli` with stdout/stderr capture |
|
||||
| `helpers/mock-open-url.ts` | Suite-wide `open-url` mock (vitest `setupFiles`) |
|
||||
| `helpers/assertions.ts` | `expectCliSuccess` / `expectCliFailure` / output matchers |
|
||||
| `helpers/fixtures.ts` | Temp config / client.json factories |
|
||||
|
||||
- `cli-runner.ts` - Invokes `runCli()` in-process and captures stdout/stderr + exit code
|
||||
- `assertions.ts` - Custom assertion helpers for CLI output validation
|
||||
- `fixtures.ts` - Test config/catalog file generators and temporary directory management
|
||||
|
||||
HTTP/SSE test servers (for transport tests) come from the
|
||||
`@modelcontextprotocol/inspector-test-server` package (`createTestServerHttp`),
|
||||
and the bundled stdio server is launched via `getTestMcpServerCommand()`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Test files run in separate forked processes (`pool: 'forks'`), so the in-process
|
||||
runner's `process.std*.write` patching never overlaps across files; tests within
|
||||
a file run sequentially
|
||||
- Config files use `crypto.randomUUID()` for uniqueness
|
||||
- HTTP/SSE servers use dynamic port allocation to avoid conflicts
|
||||
- Coverage is enforced per-file (lines ≥ 90, statements ≥ 85, functions ≥ 80,
|
||||
branches ≥ 50). `src/index.ts` (the binary bootstrap) is excluded because it
|
||||
only runs in the spawned binary, which coverage can't instrument
|
||||
- All tests use the built-in MCP test servers — no external/registry dependencies
|
||||
OAuth interactive tests stub or drive the loopback callback in-process rather
|
||||
than requiring a real browser.
|
||||
|
|
|
|||
112
clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts
Normal file
112
clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
getStateFilePath,
|
||||
resetNodeOAuthStorageCache,
|
||||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";
|
||||
|
||||
describe("clearStoredAuthForRelogin", () => {
|
||||
let dir: string | undefined;
|
||||
let prevPath: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (prevPath === undefined)
|
||||
delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath;
|
||||
resetNodeOAuthStorageCache();
|
||||
if (dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("no-ops when serverUrl is missing or blank", async () => {
|
||||
await clearStoredAuthForRelogin(undefined);
|
||||
await clearStoredAuthForRelogin(" ");
|
||||
});
|
||||
|
||||
it("clears a URL-keyed entry and tolerates non-URL keys", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com/mcp": {
|
||||
tokens: { access_token: "a", token_type: "Bearer" },
|
||||
},
|
||||
"not a url": {
|
||||
tokens: { access_token: "b", token_type: "Bearer" },
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
expect(getStateFilePath()).toBe(file);
|
||||
|
||||
await clearStoredAuthForRelogin("https://example.com/mcp");
|
||||
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
expect(blob.servers["not a url"]).toBeDefined();
|
||||
|
||||
// normalizeServerUrl catch path — clears under the raw key
|
||||
await clearStoredAuthForRelogin("not a url");
|
||||
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["not a url"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears both raw and URL-normalised keys (bare origin / mixed-case host)", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-norm-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
// Runtime storage keys by the transport's raw url string — often without a
|
||||
// trailing slash / with mixed-case host — while new URL().href normalises.
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com": {
|
||||
tokens: { access_token: "bare", token_type: "Bearer" },
|
||||
},
|
||||
"https://example.com/": {
|
||||
tokens: { access_token: "slash", token_type: "Bearer" },
|
||||
},
|
||||
"https://Example.com/mcp": {
|
||||
tokens: { access_token: "mixed", token_type: "Bearer" },
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
await clearStoredAuthForRelogin("https://example.com");
|
||||
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com"]).toBeUndefined();
|
||||
expect(blob.servers["https://example.com/"]).toBeUndefined();
|
||||
expect(blob.servers["https://Example.com/mcp"]).toBeDefined();
|
||||
|
||||
await clearStoredAuthForRelogin("https://Example.com/mcp");
|
||||
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://Example.com/mcp"]).toBeUndefined();
|
||||
// Normalised key (lowercased host) is cleared too when distinct.
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
250
clients/cli/__tests__/cli-oauth-navigation.test.ts
Normal file
250
clients/cli/__tests__/cli-oauth-navigation.test.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createCliOAuthNavigation,
|
||||
isCliAutoOpenForced,
|
||||
resolveCliAutoOpenEnabled,
|
||||
} from "../src/cli-oauth-navigation.js";
|
||||
import { openUrl } from "../src/open-url.js";
|
||||
|
||||
// Local mock (in addition to suite-wide setupFiles) so this file owns a
|
||||
// `vi.mocked(openUrl)` handle and does not depend on the global mock shape.
|
||||
vi.mock("../src/open-url.js", () => ({
|
||||
openUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const openUrlMock = vi.mocked(openUrl);
|
||||
|
||||
describe("resolveCliAutoOpenEnabled", () => {
|
||||
it("honors MCP_AUTO_OPEN_ENABLED true/false and defaults off under VITEST", () => {
|
||||
expect(
|
||||
resolveCliAutoOpenEnabled({
|
||||
MCP_AUTO_OPEN_ENABLED: "true",
|
||||
VITEST: "true",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveCliAutoOpenEnabled({
|
||||
MCP_AUTO_OPEN_ENABLED: "false",
|
||||
VITEST: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(resolveCliAutoOpenEnabled({ VITEST: "true" })).toBe(false);
|
||||
expect(resolveCliAutoOpenEnabled({})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCliAutoOpenForced", () => {
|
||||
it("is true only for the explicit string true", () => {
|
||||
expect(isCliAutoOpenForced({ MCP_AUTO_OPEN_ENABLED: "true" })).toBe(true);
|
||||
expect(isCliAutoOpenForced({ MCP_AUTO_OPEN_ENABLED: "false" })).toBe(false);
|
||||
expect(isCliAutoOpenForced({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCliOAuthNavigation", () => {
|
||||
afterEach(() => {
|
||||
openUrlMock.mockClear();
|
||||
openUrlMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("prints OSC 8 link and opens browser when armed on a TTY", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn().mockResolvedValue(undefined);
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
// Force ANSI on even when the test runner exports NO_COLOR.
|
||||
noColorEnv: "",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
const url = new URL("https://as.example/authorize?x=1");
|
||||
nav.navigateToAuthorization(url);
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
expect(lines.join("")).toContain("Please navigate to:");
|
||||
expect(lines.join("")).toContain(
|
||||
"\u001b]8;;https://as.example/authorize?x=1\u0007",
|
||||
);
|
||||
expect(openBrowser).toHaveBeenCalledWith(
|
||||
"https://as.example/authorize?x=1",
|
||||
);
|
||||
expect(openUrlMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a silent no-op when disarmed (SDK-during-connect default)", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn();
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenEnabled: true,
|
||||
// no autoOpenControl → never armed
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
// Navigation is fire-and-forget; give the microtask a turn.
|
||||
await Promise.resolve();
|
||||
expect(lines).toEqual([]);
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a silent no-op when disableAutoOpen is set (stored-auth-only)", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn();
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
disableAutoOpen: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await Promise.resolve();
|
||||
expect(lines).toEqual([]);
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints but does not open a browser when autoOpenEnabled is false", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn();
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: false,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await vi.waitFor(() => expect(lines.length).toBe(1));
|
||||
expect(lines.join("")).toContain("Please navigate to:");
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints a plain URL and does not open a browser when not a TTY (unless forced)", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn();
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: false,
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await vi.waitFor(() => expect(lines.length).toBe(1));
|
||||
expect(lines.join("")).toBe(
|
||||
"Please navigate to: https://as.example/authorize\n",
|
||||
);
|
||||
expect(lines.join("")).not.toContain("\u001b]8;;");
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
expect(openUrlMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens on a non-TTY when forceAutoOpen is set (MCP_AUTO_OPEN_ENABLED=true)", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn().mockResolvedValue(undefined);
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: false,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
forceAutoOpen: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
expect(lines.join("")).toContain("Please navigate to:");
|
||||
});
|
||||
|
||||
it("infers force-open from MCP_AUTO_OPEN_ENABLED=true when options omit overrides", async () => {
|
||||
const prev = process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
process.env.MCP_AUTO_OPEN_ENABLED = "true";
|
||||
try {
|
||||
const openBrowser = vi.fn().mockResolvedValue(undefined);
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: false,
|
||||
noColorEnv: "1",
|
||||
write: () => {},
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
} else {
|
||||
process.env.MCP_AUTO_OPEN_ENABLED = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("skips OSC 8 when NO_COLOR is set but still opens on a TTY when armed", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn().mockResolvedValue(undefined);
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/a"));
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
expect(lines.join("")).toBe("Please navigate to: https://as.example/a\n");
|
||||
expect(lines.join("")).not.toContain("\u001b]8;;");
|
||||
});
|
||||
|
||||
it("swallows browser-open failures after printing the URL", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn().mockRejectedValue(new Error("no browser"));
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/a"));
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
expect(lines.join("")).toContain("Please navigate to:");
|
||||
});
|
||||
|
||||
it("writes to stderr and uses openUrl by default when armed on a TTY", async () => {
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
const ttyDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
|
||||
Object.defineProperty(process.stderr, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
try {
|
||||
const nav = createCliOAuthNavigation({
|
||||
noColorEnv: "1",
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/default"));
|
||||
await vi.waitFor(() => expect(openUrlMock).toHaveBeenCalledOnce());
|
||||
expect(openUrlMock).toHaveBeenCalledWith("https://as.example/default");
|
||||
const written = writeSpy.mock.calls.map((c) => String(c[0])).join("");
|
||||
expect(written).toContain(
|
||||
"Please navigate to: https://as.example/default",
|
||||
);
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
if (ttyDesc) {
|
||||
Object.defineProperty(process.stderr, "isTTY", ttyDesc);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -7,22 +7,69 @@ import {
|
|||
handleCliAuthRecoveryRequired,
|
||||
isStandardOAuthStepUp,
|
||||
runCliInteractiveOAuth,
|
||||
assertInteractiveOAuthAllowed,
|
||||
withCliAuthRecoveryRetry,
|
||||
STEP_UP_PIPE_TIMEOUT_MS,
|
||||
} from "../src/cliOAuth.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
// `confirmStepUpFromStdin` (the default step-up confirmer) reads a line from
|
||||
// stdin via node:readline/promises. Mock the module so the default path can be
|
||||
// exercised deterministically without real TTY input.
|
||||
const { mockQuestion, mockClose } = vi.hoisted(() => ({
|
||||
mockQuestion: vi.fn(),
|
||||
mockClose: vi.fn(),
|
||||
}));
|
||||
const { mockQuestion, mockClose, createMockReadline } = vi.hoisted(() => {
|
||||
const mockQuestion = vi.fn();
|
||||
const mockClose = vi.fn();
|
||||
function createMockReadline(options?: {
|
||||
question?: () => Promise<string>;
|
||||
/** When set, emit `close` on the next microtask (EOF, no partial line). */
|
||||
emitClose?: boolean;
|
||||
/**
|
||||
* Emit a `line` then `close` on the next microtask — models
|
||||
* `printf 'y' | …` (partial last line without trailing newline).
|
||||
*/
|
||||
emitLineThenClose?: string;
|
||||
}) {
|
||||
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
const rl = {
|
||||
question: options?.question ?? mockQuestion,
|
||||
close: mockClose,
|
||||
on(event: string, cb: (...args: unknown[]) => void) {
|
||||
let set = listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(event, set);
|
||||
}
|
||||
set.add(cb);
|
||||
return rl;
|
||||
},
|
||||
once(event: string, cb: (...args: unknown[]) => void) {
|
||||
// Match EventEmitter.removeListener(originalFn) after once(fn).
|
||||
return rl.on(event, cb);
|
||||
},
|
||||
removeListener(event: string, cb: (...args: unknown[]) => void) {
|
||||
listeners.get(event)?.delete(cb);
|
||||
return rl;
|
||||
},
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const cb of [...(listeners.get(event) ?? [])]) cb(...args);
|
||||
},
|
||||
};
|
||||
if (options?.emitLineThenClose !== undefined) {
|
||||
const line = options.emitLineThenClose;
|
||||
queueMicrotask(() => {
|
||||
rl.emit("line", line);
|
||||
rl.emit("close");
|
||||
});
|
||||
} else if (options?.emitClose) {
|
||||
queueMicrotask(() => rl.emit("close"));
|
||||
}
|
||||
return rl;
|
||||
}
|
||||
return { mockQuestion, mockClose, createMockReadline };
|
||||
});
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: mockQuestion,
|
||||
close: mockClose,
|
||||
})),
|
||||
createInterface: vi.fn(() => createMockReadline()),
|
||||
}));
|
||||
|
||||
const CALLBACK_URL_CONFIG = {
|
||||
|
|
@ -31,6 +78,19 @@ const CALLBACK_URL_CONFIG = {
|
|||
pathname: "/oauth/callback",
|
||||
};
|
||||
|
||||
const OAUTH_HTTP_CONFIG = {
|
||||
type: "streamable-http",
|
||||
url: "https://as.example/mcp",
|
||||
} as MCPServerConfig;
|
||||
|
||||
const STDIO_CONFIG = {
|
||||
type: "stdio",
|
||||
command: "x",
|
||||
} as MCPServerConfig;
|
||||
|
||||
/** Unit tests run without a TTY; opt into interactive OAuth explicitly. */
|
||||
const INTERACTIVE = { isTTY: true as const };
|
||||
|
||||
describe("cliOAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -82,6 +142,37 @@ describe("cliOAuth", () => {
|
|||
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth arms autoOpenControl only for the interactive flow", async () => {
|
||||
const autoOpenControl = { armed: false };
|
||||
let armedDuringCall = false;
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockImplementation(
|
||||
async () => {
|
||||
armedDuringCall = autoOpenControl.armed;
|
||||
return { kind: "success" };
|
||||
},
|
||||
);
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
await runCliInteractiveOAuth(
|
||||
client,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{ autoOpenControl },
|
||||
);
|
||||
|
||||
expect(armedDuringCall).toBe(true);
|
||||
expect(autoOpenControl.armed).toBe(false);
|
||||
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => {
|
||||
const challenge = {
|
||||
reason: "insufficient_scope" as const,
|
||||
|
|
@ -130,7 +221,7 @@ describe("cliOAuth", () => {
|
|||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{},
|
||||
async () => false,
|
||||
{ confirmStepUp: async () => false, isTTY: true },
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
|
|
@ -160,7 +251,7 @@ describe("cliOAuth", () => {
|
|||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{},
|
||||
async () => true,
|
||||
{ confirmStepUp: async () => true, isTTY: true },
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalledWith(
|
||||
|
|
@ -224,7 +315,10 @@ describe("cliOAuth", () => {
|
|||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "success",
|
||||
});
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
|
|
@ -241,14 +335,16 @@ describe("cliOAuth", () => {
|
|||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
client,
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{ enterpriseManaged: true },
|
||||
fn,
|
||||
async () => true,
|
||||
{ confirmStepUp: async () => true, isTTY: true },
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
|
|
@ -272,14 +368,15 @@ describe("cliOAuth", () => {
|
|||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
|
||||
// Omitting the confirmStepUp argument exercises the default
|
||||
// confirmStepUpFromStdin, which reads from the mocked readline interface.
|
||||
// Omitting confirmStepUp exercises the default confirmStepUpFromStdin,
|
||||
// which reads from the mocked readline interface.
|
||||
await handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(mockQuestion).toHaveBeenCalled();
|
||||
|
|
@ -299,6 +396,7 @@ describe("cliOAuth", () => {
|
|||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
|
|
@ -315,19 +413,177 @@ describe("cliOAuth", () => {
|
|||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declines when readline question rejects", async () => {
|
||||
mockQuestion.mockRejectedValue(new Error("stdin closed"));
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declines when stdin EOF closes the readline interface before an answer", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
vi.mocked(createInterface).mockImplementationOnce(
|
||||
() =>
|
||||
createMockReadline({
|
||||
question: () => new Promise(() => {}),
|
||||
emitClose: true,
|
||||
}) as unknown as ReturnType<typeof createInterface>,
|
||||
);
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a partial last line without trailing newline (printf y | …)", async () => {
|
||||
// On stream end, readline emits the buffered partial line then close
|
||||
// without settling question() — close must use the captured line.
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
vi.mocked(createInterface).mockImplementationOnce(
|
||||
() =>
|
||||
createMockReadline({
|
||||
question: () => new Promise(() => {}),
|
||||
emitLineThenClose: "y",
|
||||
}) as unknown as ReturnType<typeof createInterface>,
|
||||
);
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a piped y when stdin is non-TTY (echo y | …)", async () => {
|
||||
// Force-admit interactive OAuth without a TTY; the confirmer still reads
|
||||
// the piped answer (round-9: do not hard-require stdin.isTTY).
|
||||
vi.stubEnv("MCP_AUTO_OPEN_ENABLED", "true");
|
||||
mockQuestion.mockResolvedValue("y");
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
try {
|
||||
await handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
{ isTTY: false },
|
||||
);
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails with auth_required when a non-TTY stdin never answers (pipe timeout)", async () => {
|
||||
mockQuestion.mockImplementation(() => new Promise(() => {}));
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
{ isTTY: true, stepUpPromptTimeoutMs: 20 },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
message: expect.stringMatching(/no answer on stdin within 20ms/),
|
||||
});
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not apply the pipe timeout when stdin is a TTY", async () => {
|
||||
vi.useFakeTimers();
|
||||
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
let resolveQuestion!: (value: string) => void;
|
||||
mockQuestion.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveQuestion = resolve;
|
||||
}),
|
||||
);
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
try {
|
||||
const pending = handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
);
|
||||
let settled = false;
|
||||
void pending.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(STEP_UP_PIPE_TIMEOUT_MS + 60_000);
|
||||
await Promise.resolve(); // flush microtasks from any premature settle
|
||||
expect(settled).toBe(false);
|
||||
resolveQuestion("y");
|
||||
await pending;
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectInspectorWithOAuth recovery branch", () => {
|
||||
const oauthServerConfig = {
|
||||
type: "streamable-http",
|
||||
url: "https://as.example/mcp",
|
||||
} as MCPServerConfig;
|
||||
const oauthServerConfig = OAUTH_HTTP_CONFIG;
|
||||
|
||||
it("resumes without re-auth when storage already satisfies the challenge", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
|
|
@ -381,6 +637,8 @@ describe("cliOAuth", () => {
|
|||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -407,6 +665,8 @@ describe("cliOAuth", () => {
|
|||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(client.disconnect).toHaveBeenCalled();
|
||||
|
|
@ -453,5 +713,479 @@ describe("cliOAuth", () => {
|
|||
),
|
||||
).rejects.toThrow("nope (401)");
|
||||
});
|
||||
|
||||
it("fails with AUTH_REQUIRED under --stored-auth-only without interactive OAuth", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const connect = vi.fn().mockRejectedValue(
|
||||
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||
reason: "token_expired",
|
||||
}),
|
||||
);
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
await expect(
|
||||
connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("under --stored-auth-only reconnects when the store already satisfies", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const connect = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "token_expired" },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
{ storedAuthOnly: true },
|
||||
);
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(2);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails stored-auth-only on plain unauthorized errors", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const connect = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Connection failed for server (401)"));
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry respects storedAuthOnly", async () => {
|
||||
const checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(false);
|
||||
const fn = vi.fn().mockRejectedValue(
|
||||
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||
reason: "token_expired",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied,
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
expect(checkAuthChallengeSatisfied).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry under storedAuthOnly retries when the store satisfies the challenge", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const challenge = {
|
||||
reason: "token_expired" as const,
|
||||
};
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
challenge,
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce("ok");
|
||||
const checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true);
|
||||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied,
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ storedAuthOnly: true },
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(checkAuthChallengeSatisfied).toHaveBeenCalledWith(challenge);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry recovers plain unauthorized errors like connect", async () => {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
const disconnect = vi.fn().mockResolvedValue(undefined);
|
||||
const callOrder: string[] = [];
|
||||
connect.mockImplementation(async () => {
|
||||
callOrder.push("connect");
|
||||
});
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
callOrder.push("fn1");
|
||||
throw new Error("RPC failed (401)");
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
callOrder.push("fn2");
|
||||
return "ok";
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect,
|
||||
disconnect,
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
expect(runSpy).toHaveBeenCalledOnce();
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(callOrder).toEqual(["fn1", "connect", "fn2"]);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
"Authorization complete. Retrying…\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry fails stored-auth-only on plain unauthorized errors", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const fn = vi.fn().mockRejectedValue(new Error("RPC failed (401)"));
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry rethrows unauthorized errors for non-OAuth configs", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const err = new Error("proxied backend failed with status (401)");
|
||||
const fn = vi.fn().mockRejectedValue(err);
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
STDIO_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
),
|
||||
).rejects.toBe(err);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry rethrows unrelated errors unchanged", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const err = new Error("something else went wrong");
|
||||
const fn = vi.fn().mockRejectedValue(err);
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
),
|
||||
).rejects.toBe(err);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry stored-auth-only uses a fallback message for non-Error 401s", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const fn = vi.fn().mockRejectedValue({ status: 401 });
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
message: expect.stringMatching(/--stored-auth-only/),
|
||||
});
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assertInteractiveOAuthAllowed fails fast when neither stream is a TTY", () => {
|
||||
expect(() => assertInteractiveOAuthAllowed({ isTTY: false })).toThrow(
|
||||
/Interactive OAuth requires a TTY on stdin or stderr/,
|
||||
);
|
||||
});
|
||||
|
||||
it("assertInteractiveOAuthAllowed admits when isTTY override is true (stdin||stderr path)", () => {
|
||||
expect(() => assertInteractiveOAuthAllowed({ isTTY: true })).not.toThrow();
|
||||
});
|
||||
|
||||
it("assertInteractiveOAuthAllowed admits when stdin is a TTY even if stderr is not", () => {
|
||||
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
||||
const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
Object.defineProperty(process.stderr, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
try {
|
||||
expect(() => assertInteractiveOAuthAllowed()).not.toThrow();
|
||||
} finally {
|
||||
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY;
|
||||
if (stderrDesc)
|
||||
Object.defineProperty(process.stderr, "isTTY", stderrDesc);
|
||||
else delete (process.stderr as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
|
||||
it("assertInteractiveOAuthAllowed fails when both stdin and stderr are non-TTY", () => {
|
||||
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
||||
const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
Object.defineProperty(process.stderr, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
try {
|
||||
expect(() => assertInteractiveOAuthAllowed()).toThrow(
|
||||
/Interactive OAuth requires a TTY on stdin or stderr/,
|
||||
);
|
||||
} finally {
|
||||
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY;
|
||||
if (stderrDesc)
|
||||
Object.defineProperty(process.stderr, "isTTY", stderrDesc);
|
||||
else delete (process.stderr as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth is quiet on already_authorized", async () => {
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "already_authorized",
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
await runCliInteractiveOAuth(
|
||||
{
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
);
|
||||
expect(stderrSpy).not.toHaveBeenCalledWith("Authorization complete.\n");
|
||||
});
|
||||
|
||||
it("connectInspectorWithOAuth stored-auth-only uses fallback when AuthRecovery message is empty", async () => {
|
||||
const err = new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "token_expired" },
|
||||
);
|
||||
Object.defineProperty(err, "message", { value: "" });
|
||||
const connect = vi.fn().mockRejectedValue(err);
|
||||
await expect(
|
||||
connectInspectorWithOAuth(
|
||||
{
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
message: expect.stringMatching(/--stored-auth-only/),
|
||||
});
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry refuses interactive OAuth on a non-TTY", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const fn = vi.fn().mockRejectedValue(new Error("RPC failed (401)"));
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ isTTY: false },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
message: expect.stringMatching(/--stored-auth-only/),
|
||||
});
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry allows non-TTY interactive OAuth when MCP_AUTO_OPEN_ENABLED=true", async () => {
|
||||
const prev = process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
process.env.MCP_AUTO_OPEN_ENABLED = "true";
|
||||
try {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("RPC failed (401)"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect,
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ isTTY: false },
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(runSpy).toHaveBeenCalledOnce();
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
} else {
|
||||
process.env.MCP_AUTO_OPEN_ENABLED = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ describe("emitResult", () => {
|
|||
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",
|
||||
|
|
@ -108,6 +124,25 @@ describe("collectAppInfo", () => {
|
|||
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
|
||||
|
|
|
|||
50
clients/cli/__tests__/format-output.test.ts
Normal file
50
clients/cli/__tests__/format-output.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { writeFormattedResult } from "../src/handlers/format-output.js";
|
||||
|
||||
describe("writeFormattedResult", () => {
|
||||
let originalWrite: typeof process.stdout.write;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWrite) process.stdout.write = originalWrite;
|
||||
});
|
||||
|
||||
function captureStdout(): { get: () => string; restore: () => void } {
|
||||
let out = "";
|
||||
originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
out += String(chunk);
|
||||
const cb = rest.find((x) => typeof x === "function") as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
cb?.();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
return {
|
||||
get: () => out,
|
||||
restore: () => {
|
||||
process.stdout.write = originalWrite;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("defaults to pretty text and supports the json envelope", async () => {
|
||||
const cap = captureStdout();
|
||||
try {
|
||||
await writeFormattedResult({ ok: 1 });
|
||||
expect(cap.get()).toContain('"ok": 1');
|
||||
expect(cap.get()).not.toContain('"result"');
|
||||
|
||||
// reset buffer by re-capturing
|
||||
cap.restore();
|
||||
const cap2 = captureStdout();
|
||||
try {
|
||||
await writeFormattedResult({ ok: 2 }, "json");
|
||||
expect(JSON.parse(cap2.get())).toEqual({ result: { ok: 2 } });
|
||||
} finally {
|
||||
cap2.restore();
|
||||
}
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
10
clients/cli/__tests__/helpers/mock-open-url.ts
Normal file
10
clients/cli/__tests__/helpers/mock-open-url.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Structural guard for the whole CLI suite: armed interactive OAuth must not
|
||||
* shell out to a real browser. Registered via vitest `setupFiles` so it does
|
||||
* not depend on import order in individual test files.
|
||||
*/
|
||||
vi.mock("../../src/open-url.js", () => ({
|
||||
openUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
|
@ -428,6 +428,43 @@ describe("Metadata Tests", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("JSON.stringifies object/array metadata (not String → [object Object])", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
'nested={"key":"value"}',
|
||||
"list=[1,2,3]",
|
||||
"flag=true",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
nested: '{"key":"value"}',
|
||||
list: "[1,2,3]",
|
||||
flag: "true",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle special characters", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
|
|
|
|||
36
clients/cli/__tests__/method-types.test.ts
Normal file
36
clients/cli/__tests__/method-types.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isOneShotMethod,
|
||||
metaValueToString,
|
||||
ONE_SHOT_METHODS,
|
||||
SESSION_RPC_METHODS,
|
||||
} from "../src/handlers/method-types.js";
|
||||
|
||||
describe("SESSION_RPC_METHODS", () => {
|
||||
it("lists the full RPC method set supported by runMethod", () => {
|
||||
expect(SESSION_RPC_METHODS).toContain("tools/list");
|
||||
expect(SESSION_RPC_METHODS).toContain("tools/call");
|
||||
expect(SESSION_RPC_METHODS).toContain("logging/tail");
|
||||
expect(SESSION_RPC_METHODS).toContain("roots/set");
|
||||
expect(new Set(SESSION_RPC_METHODS).size).toBe(SESSION_RPC_METHODS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ONE_SHOT_METHODS", () => {
|
||||
it("excludes stream and session-only methods", () => {
|
||||
expect(isOneShotMethod("tools/list")).toBe(true);
|
||||
expect(isOneShotMethod("logging/setLevel")).toBe(true);
|
||||
expect(isOneShotMethod("logging/tail")).toBe(false);
|
||||
expect(isOneShotMethod("resources/subscribe")).toBe(false);
|
||||
expect(isOneShotMethod("tasks/list")).toBe(false);
|
||||
expect(ONE_SHOT_METHODS).not.toContain("logging/tail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metaValueToString", () => {
|
||||
it("passes strings through and JSON-encodes structured values", () => {
|
||||
expect(metaValueToString("plain")).toBe("plain");
|
||||
expect(metaValueToString({ a: 1 })).toBe('{"a":1}');
|
||||
expect(metaValueToString([1, 2])).toBe("[1,2]");
|
||||
});
|
||||
});
|
||||
|
|
@ -59,20 +59,43 @@ describe("CLI method coverage", () => {
|
|||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
});
|
||||
|
||||
it("throws for an unsupported method after connecting", async () => {
|
||||
// Reaches the final `else` in callMethod: the connection succeeds, none of
|
||||
// the known method branches match, so it throws "Unsupported method".
|
||||
const result = await runCli([
|
||||
it("rejects unsupported and stream methods before connecting", async () => {
|
||||
const unknown = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"definitely/not-a-real-method",
|
||||
]);
|
||||
expectCliFailure(unknown);
|
||||
expectOutputContains(unknown, "Unsupported method");
|
||||
expectOutputContains(unknown, "definitely/not-a-real-method");
|
||||
|
||||
// Long-lived / stream methods are rejected by the CLI method allowlist.
|
||||
const tail = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"logging/tail",
|
||||
]);
|
||||
expectCliFailure(tail);
|
||||
expectOutputContains(tail, "Unsupported method");
|
||||
expectOutputContains(tail, "logging/tail");
|
||||
});
|
||||
|
||||
it("rejects bad methods before --use-stored-auth network work", async () => {
|
||||
// No oauth.json / refresh — must fail on allowlist, not no_stored_token.
|
||||
const result = await runCli([
|
||||
"--method",
|
||||
"logging/tail",
|
||||
"--use-stored-auth",
|
||||
"--server-url",
|
||||
"https://example.com/mcp",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expectOutputContains(result, "Unsupported method");
|
||||
expectOutputContains(result, "definitely/not-a-real-method");
|
||||
expect(result.stderr).not.toMatch(/No stored OAuth token/);
|
||||
});
|
||||
|
||||
it("rejects an invalid --transport value before connecting", async () => {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
undefined,
|
||||
{ isTTY: true },
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
|
|
@ -206,6 +208,8 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
undefined,
|
||||
{ isTTY: true },
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
|
|
@ -225,6 +229,7 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
try {
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
client,
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
{},
|
||||
|
|
@ -233,7 +238,7 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
city: "NYC",
|
||||
units: "C",
|
||||
}),
|
||||
async () => true,
|
||||
{ confirmStepUp: async () => true, isTTY: true },
|
||||
);
|
||||
|
||||
expect(stepUpPrompted).toBe(true);
|
||||
|
|
|
|||
31
clients/cli/__tests__/open-url.test.ts
Normal file
31
clients/cli/__tests__/open-url.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const openMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("open", () => ({
|
||||
default: (...args: unknown[]) => openMock(...args),
|
||||
}));
|
||||
|
||||
// Suite-wide setup mocks open-url; this file exercises the real wrapper.
|
||||
vi.unmock("../src/open-url.js");
|
||||
|
||||
describe("openUrl", () => {
|
||||
beforeEach(() => {
|
||||
openMock.mockClear();
|
||||
openMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("forwards a string URL to open", async () => {
|
||||
const { openUrl } = await import("../src/open-url.js");
|
||||
await openUrl("https://example.com/auth");
|
||||
expect(openMock).toHaveBeenCalledWith("https://example.com/auth");
|
||||
});
|
||||
|
||||
it("forwards URL.href for URL instances", async () => {
|
||||
const { openUrl } = await import("../src/open-url.js");
|
||||
await openUrl(new URL("https://example.com/callback?code=1"));
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
"https://example.com/callback?code=1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -135,6 +135,98 @@ describe("--tool-args-json", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("CLI --relogin flag conflicts", () => {
|
||||
it("rejects --relogin with --stored-auth-only", async () => {
|
||||
const result = await runCli([
|
||||
"--relogin",
|
||||
"--stored-auth-only",
|
||||
"--server-url",
|
||||
"https://example.com/mcp",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(
|
||||
/--relogin cannot be combined with --stored-auth-only/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects --relogin with --use-stored-auth", async () => {
|
||||
const result = await runCli([
|
||||
"--relogin",
|
||||
"--use-stored-auth",
|
||||
"--server-url",
|
||||
"https://example.com/mcp",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/--use-stored-auth/);
|
||||
});
|
||||
|
||||
it("rejects --relogin with catalog-only methods", async () => {
|
||||
const result = await runCli(["--relogin", "--method", "servers/list"]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/servers\/list or servers\/show/);
|
||||
});
|
||||
|
||||
it("rejects --relogin with --list-stored-auth", async () => {
|
||||
const result = await runCli(["--relogin", "--list-stored-auth"]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/--list-stored-auth or --print-handoff/);
|
||||
});
|
||||
|
||||
it("rejects --relogin for stdio servers", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--relogin",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/HTTP\/SSE server URL/);
|
||||
});
|
||||
|
||||
it("accepts --relogin and clears stored auth before connect", async () => {
|
||||
// Unreachable port: proves --relogin runs (clears store) then fails connect.
|
||||
const result = await runCli([
|
||||
"--relogin",
|
||||
"--server-url",
|
||||
"http://127.0.0.1:1/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
"--connect-timeout",
|
||||
"200",
|
||||
"--method",
|
||||
"initialize",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI --stored-auth-only wiring", () => {
|
||||
it("accepts the flag and fails connect without interactive OAuth prompts", async () => {
|
||||
// Unreachable host: proves the flag is accepted through parse → callMethod
|
||||
// and does not hang on an interactive OAuth prompt.
|
||||
const result = await runCli([
|
||||
"--stored-auth-only",
|
||||
"--server-url",
|
||||
"http://127.0.0.1:1/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
"--connect-timeout",
|
||||
"200",
|
||||
"--method",
|
||||
"initialize",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).not.toMatch(/Please navigate to:/);
|
||||
expect(result.stderr).not.toMatch(/Proceed with step-up/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP_CATALOG_PATH with an ad-hoc target", () => {
|
||||
it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
|
|
|
|||
232
clients/cli/__tests__/run-method-mocks.test.ts
Normal file
232
clients/cli/__tests__/run-method-mocks.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runMethod } from "../src/handlers/run-method.js";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
|
||||
function mockClient(overrides: Partial<InspectorClient> = {}): InspectorClient {
|
||||
const base = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
subscribeToResource: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribeFromResource: vi.fn().mockResolvedValue(undefined),
|
||||
getCompletions: vi.fn().mockResolvedValue({ values: ["a"] }),
|
||||
getRequestorTask: vi.fn().mockResolvedValue({ taskId: "t1" }),
|
||||
cancelRequestorTask: vi.fn().mockResolvedValue(undefined),
|
||||
getRequestorTaskResult: vi.fn().mockResolvedValue({ content: [] }),
|
||||
getRoots: vi.fn().mockReturnValue([]),
|
||||
setRoots: vi.fn().mockResolvedValue(undefined),
|
||||
setLoggingLevel: vi.fn().mockResolvedValue(undefined),
|
||||
getServerInfo: vi.fn().mockReturnValue({ name: "t" }),
|
||||
getProtocolVersion: vi.fn().mockReturnValue("1"),
|
||||
getCapabilities: vi.fn().mockReturnValue({}),
|
||||
getInstructions: vi.fn().mockReturnValue(undefined),
|
||||
callTool: vi.fn(),
|
||||
callToolStream: vi.fn(),
|
||||
readResource: vi.fn(),
|
||||
getPrompt: vi.fn(),
|
||||
};
|
||||
return { ...base, ...overrides } as unknown as InspectorClient;
|
||||
}
|
||||
|
||||
vi.mock("@inspector/core/mcp/state/index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@inspector/core/mcp/state/index.js")>();
|
||||
class FakeManaged {
|
||||
setMetadata = vi.fn();
|
||||
refresh = vi.fn().mockResolvedValue(undefined);
|
||||
destroy = vi.fn();
|
||||
getTools = vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: "echo", description: "e", inputSchema: { type: "object" } },
|
||||
]);
|
||||
getResources = vi.fn().mockReturnValue([]);
|
||||
getResourceTemplates = vi.fn().mockReturnValue([]);
|
||||
getPrompts = vi.fn().mockReturnValue([{ name: "p" }]);
|
||||
getTasks = vi.fn().mockReturnValue([{ taskId: "t1" }]);
|
||||
}
|
||||
class FakeLog {
|
||||
static last: FakeLog | undefined;
|
||||
addEventListener = vi.fn();
|
||||
removeEventListener = vi.fn();
|
||||
destroy = vi.fn();
|
||||
constructor() {
|
||||
FakeLog.last = this;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
ManagedToolsState: FakeManaged,
|
||||
ManagedResourcesState: FakeManaged,
|
||||
ManagedResourceTemplatesState: FakeManaged,
|
||||
ManagedPromptsState: FakeManaged,
|
||||
ManagedRequestorTasksState: FakeManaged,
|
||||
MessageLogState: FakeLog,
|
||||
};
|
||||
});
|
||||
|
||||
describe("runMethod (mocked client)", () => {
|
||||
it("covers subscribe stream, tasks, complete, and app-info call", async () => {
|
||||
const client = mockClient({
|
||||
callTool: vi.fn().mockResolvedValue({
|
||||
result: { content: [{ type: "text", text: "ok" }] },
|
||||
toolName: "echo",
|
||||
params: {},
|
||||
timestamp: new Date(),
|
||||
success: true,
|
||||
}),
|
||||
callToolStream: vi.fn().mockResolvedValue({
|
||||
result: { content: [] },
|
||||
toolName: "echo",
|
||||
params: {},
|
||||
timestamp: new Date(),
|
||||
success: true,
|
||||
}),
|
||||
readResource: vi.fn().mockResolvedValue({
|
||||
result: { contents: [] },
|
||||
}),
|
||||
});
|
||||
|
||||
const sub = await runMethod(client, {
|
||||
method: "resources/subscribe",
|
||||
uri: "test://x",
|
||||
});
|
||||
expect(sub.kind).toBe("stream");
|
||||
if (sub.kind === "stream") {
|
||||
const lines: unknown[] = [];
|
||||
const stop = sub.start((o) => lines.push(o));
|
||||
expect(lines[0]).toMatchObject({ type: "subscribed" });
|
||||
// Simulate update event
|
||||
const listener = (
|
||||
client.addEventListener as ReturnType<typeof vi.fn>
|
||||
).mock.calls.find((c) => c[0] === "resourceUpdated")?.[1] as (
|
||||
ev: Event,
|
||||
) => void;
|
||||
listener?.(
|
||||
new CustomEvent("resourceUpdated", { detail: { uri: "test://x" } }),
|
||||
);
|
||||
expect(
|
||||
lines.some(
|
||||
(l) => (l as { type?: string }).type === "resources/updated",
|
||||
),
|
||||
).toBe(true);
|
||||
stop();
|
||||
}
|
||||
|
||||
const tasks = await runMethod(client, { method: "tasks/list" });
|
||||
expect(tasks.kind).toBe("result");
|
||||
|
||||
const got = await runMethod(client, {
|
||||
method: "tasks/get",
|
||||
taskId: "t1",
|
||||
});
|
||||
expect(got.kind).toBe("result");
|
||||
|
||||
const cancelled = await runMethod(client, {
|
||||
method: "tasks/cancel",
|
||||
taskId: "t1",
|
||||
});
|
||||
expect(cancelled.kind).toBe("result");
|
||||
|
||||
const result = await runMethod(client, {
|
||||
method: "tasks/result",
|
||||
taskId: "t1",
|
||||
});
|
||||
expect(result.kind).toBe("result");
|
||||
|
||||
const complete = await runMethod(client, {
|
||||
method: "prompts/complete",
|
||||
completeRefType: "ref/prompt",
|
||||
completeRef: "p",
|
||||
completeArgName: "a",
|
||||
completeArgValue: "x",
|
||||
});
|
||||
expect(complete.kind).toBe("result");
|
||||
|
||||
const completeRes = await runMethod(client, {
|
||||
method: "prompts/complete",
|
||||
completeRefType: "ref/resource",
|
||||
completeRef: "test://x",
|
||||
completeArgName: "a",
|
||||
});
|
||||
expect(completeRes.kind).toBe("result");
|
||||
|
||||
const appOnly = await runMethod(client, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
appInfo: true,
|
||||
});
|
||||
expect(appOnly.kind).toBe("result");
|
||||
|
||||
const tasked = await runMethod(client, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
task: true,
|
||||
toolArg: { message: "x" },
|
||||
});
|
||||
expect(tasked.kind).toBe("result");
|
||||
expect(client.callToolStream).toHaveBeenCalled();
|
||||
|
||||
vi.mocked(client.callTool).mockResolvedValueOnce({
|
||||
result: null,
|
||||
error: "Tool call failed hard",
|
||||
toolName: "echo",
|
||||
params: {},
|
||||
timestamp: new Date(),
|
||||
success: false,
|
||||
} as never);
|
||||
const nullResult = await runMethod(client, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
toolArg: { message: "x" },
|
||||
});
|
||||
expect(nullResult.kind).toBe("result");
|
||||
if (nullResult.kind === "result") {
|
||||
expect(nullResult.result).toMatchObject({ isError: true });
|
||||
}
|
||||
|
||||
await expect(runMethod(client, { method: "tasks/cancel" })).rejects.toThrow(
|
||||
/tasks\/cancel/,
|
||||
);
|
||||
await expect(runMethod(client, { method: "tasks/result" })).rejects.toThrow(
|
||||
/tasks\/result/,
|
||||
);
|
||||
|
||||
await expect(
|
||||
runMethod(client, {
|
||||
method: "roots/set",
|
||||
rootsJson: "not-json",
|
||||
}),
|
||||
).rejects.toThrow(/roots-json is invalid/);
|
||||
|
||||
const tail = await runMethod(client, { method: "logging/tail" });
|
||||
expect(tail.kind).toBe("stream");
|
||||
if (tail.kind === "stream") {
|
||||
const lines: unknown[] = [];
|
||||
const stop = tail.start((o) => lines.push(o));
|
||||
const logMod = await import("@inspector/core/mcp/state/index.js");
|
||||
const FakeLog = logMod.MessageLogState as unknown as {
|
||||
last?: {
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
const listener = FakeLog.last?.addEventListener.mock.calls[0]?.[1] as
|
||||
| ((ev: Event) => void)
|
||||
| undefined;
|
||||
listener?.(
|
||||
new CustomEvent("message", {
|
||||
detail: {
|
||||
direction: "notification",
|
||||
message: { method: "notifications/message" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
listener?.(
|
||||
new CustomEvent("message", {
|
||||
detail: { direction: "request", message: { method: "tools/list" } },
|
||||
}),
|
||||
);
|
||||
expect(lines).toHaveLength(1);
|
||||
stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
249
clients/cli/__tests__/run-method.test.ts
Normal file
249
clients/cli/__tests__/run-method.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/index.js";
|
||||
import { runMethod } from "../src/handlers/run-method.js";
|
||||
import { consumeMethodOutcome } from "../src/handlers/consume-outcome.js";
|
||||
|
||||
describe("runMethod", () => {
|
||||
let client: InspectorClient | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
await client.disconnect().catch(() => {});
|
||||
client = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function connectStdio(): Promise<InspectorClient> {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
client = new InspectorClient(
|
||||
{ type: "stdio", command, args },
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
clientIdentity: { name: "test", version: "0.0.0" },
|
||||
progress: false,
|
||||
sample: false,
|
||||
elicit: false,
|
||||
},
|
||||
);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
it("lists tools and reads initialize fields", async () => {
|
||||
const c = await connectStdio();
|
||||
const tools = await runMethod(c, { method: "tools/list" });
|
||||
expect(tools.kind).toBe("result");
|
||||
if (tools.kind === "result") {
|
||||
expect(
|
||||
(tools.result.tools as unknown[] | undefined)?.length,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const init = await runMethod(c, { method: "initialize" });
|
||||
expect(init.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("returns ndjson lines for tools/list --app-info", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, {
|
||||
method: "tools/list",
|
||||
appInfo: true,
|
||||
});
|
||||
expect(outcome.kind).toBe("ndjson");
|
||||
if (outcome.kind === "ndjson") {
|
||||
expect(outcome.lines.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists resources and prompts", async () => {
|
||||
const c = await connectStdio();
|
||||
const resources = await runMethod(c, { method: "resources/list" });
|
||||
expect(resources.kind).toBe("result");
|
||||
const prompts = await runMethod(c, { method: "prompts/list" });
|
||||
expect(prompts.kind).toBe("result");
|
||||
const templates = await runMethod(c, {
|
||||
method: "resources/templates/list",
|
||||
});
|
||||
expect(templates.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("sets logging level", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, {
|
||||
method: "logging/setLevel",
|
||||
logLevel: "info",
|
||||
});
|
||||
expect(outcome.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("roots/list and roots/set round-trip", async () => {
|
||||
const c = await connectStdio();
|
||||
const listed = await runMethod(c, { method: "roots/list" });
|
||||
expect(listed.kind).toBe("result");
|
||||
const set = await runMethod(c, {
|
||||
method: "roots/set",
|
||||
rootsJson: JSON.stringify([{ uri: "file:///tmp", name: "tmp" }]),
|
||||
});
|
||||
expect(set.kind).toBe("result");
|
||||
if (set.kind === "result") {
|
||||
expect(set.result.roots).toEqual([{ uri: "file:///tmp", name: "tmp" }]);
|
||||
}
|
||||
});
|
||||
|
||||
it("consumeMethodOutcome writes result json", async () => {
|
||||
let stdout = "";
|
||||
const original = process.stdout.write;
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
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;
|
||||
try {
|
||||
await consumeMethodOutcome(
|
||||
{ kind: "result", result: { ok: true } },
|
||||
{ format: "json" },
|
||||
);
|
||||
expect(JSON.parse(stdout.trim())).toEqual({ result: { ok: true } });
|
||||
} finally {
|
||||
process.stdout.write = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown methods", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "nope/method" })).rejects.toThrow(
|
||||
/Unsupported method/,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls echo tool and rejects missing tool name", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "tools/call" })).rejects.toThrow(
|
||||
/Tool name is required/,
|
||||
);
|
||||
|
||||
const missing = runMethod(c, {
|
||||
method: "tools/call",
|
||||
toolName: "does-not-exist",
|
||||
});
|
||||
await expect(missing).rejects.toMatchObject({ exitCode: 5 });
|
||||
|
||||
const called = await runMethod(c, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
toolArg: { message: "hi" },
|
||||
format: "json",
|
||||
});
|
||||
expect(called.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("reads a resource and gets a prompt", async () => {
|
||||
const c = await connectStdio();
|
||||
const read = await runMethod(c, {
|
||||
method: "resources/read",
|
||||
uri: "test://env",
|
||||
});
|
||||
expect(read.kind).toBe("result");
|
||||
|
||||
await expect(runMethod(c, { method: "resources/read" })).rejects.toThrow(
|
||||
/URI is required/,
|
||||
);
|
||||
|
||||
const prompts = await runMethod(c, { method: "prompts/list" });
|
||||
expect(prompts.kind).toBe("result");
|
||||
if (prompts.kind === "result") {
|
||||
const list = prompts.result.prompts as { name: string }[];
|
||||
if (list.length > 0) {
|
||||
const got = await runMethod(c, {
|
||||
method: "prompts/get",
|
||||
promptName: list[0]!.name,
|
||||
});
|
||||
expect(got.kind).toBe("result");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a stream starter for logging/tail", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, { method: "logging/tail" });
|
||||
expect(outcome.kind).toBe("stream");
|
||||
if (outcome.kind === "stream") {
|
||||
const stop = outcome.start(() => {});
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires uri for subscribe/unsubscribe", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(
|
||||
runMethod(c, { method: "resources/subscribe" }),
|
||||
).rejects.toThrow(/URI is required/);
|
||||
await expect(
|
||||
runMethod(c, { method: "resources/unsubscribe" }),
|
||||
).rejects.toThrow(/URI is required/);
|
||||
});
|
||||
|
||||
it("rejects bad roots/set JSON and missing logging level", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "logging/setLevel" })).rejects.toThrow(
|
||||
/Log level is required/,
|
||||
);
|
||||
await expect(
|
||||
runMethod(c, { method: "roots/set", rootsJson: "{}" }),
|
||||
).rejects.toThrow(/must be a JSON array/);
|
||||
await expect(runMethod(c, { method: "roots/set" })).rejects.toThrow(
|
||||
/roots\/set requires/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "tasks/get" })).rejects.toThrow(
|
||||
/Task id is required/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "prompts/get" })).rejects.toThrow(
|
||||
/Prompt name is required/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "prompts/complete" })).rejects.toThrow(
|
||||
/prompts\/complete requires/,
|
||||
);
|
||||
});
|
||||
|
||||
it("consumeMethodOutcome writes ndjson and stream until SIGINT", async () => {
|
||||
let stdout = "";
|
||||
const original = process.stdout.write;
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
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;
|
||||
try {
|
||||
await consumeMethodOutcome(
|
||||
{ kind: "ndjson", lines: [{ a: 1 }, { b: 2 }] },
|
||||
{},
|
||||
);
|
||||
expect(stdout.trim().split("\n")).toHaveLength(2);
|
||||
|
||||
const streamPromise = consumeMethodOutcome(
|
||||
{
|
||||
kind: "stream",
|
||||
label: "t",
|
||||
start: (write) => {
|
||||
write({ hi: true });
|
||||
queueMicrotask(() => process.emit("SIGINT"));
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
await streamPromise;
|
||||
expect(stdout).toContain('"hi":true');
|
||||
} finally {
|
||||
process.stdout.write = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
355
clients/cli/__tests__/servers-list.test.ts
Normal file
355
clients/cli/__tests__/servers-list.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { runCli } from "./helpers/cli-runner.js";
|
||||
import {
|
||||
createSampleTestConfig,
|
||||
createTestConfig,
|
||||
deleteConfigFile,
|
||||
} from "./helpers/fixtures.js";
|
||||
import { expectCliSuccess } from "./helpers/assertions.js";
|
||||
import {
|
||||
annotateServerEntriesWithSessions,
|
||||
listServerEntries,
|
||||
sanitizeServerConfig,
|
||||
sanitizeServerSettings,
|
||||
showServerEntry,
|
||||
summarizeServerConfig,
|
||||
} from "../src/handlers/servers-list.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { expectCliFailure } from "./helpers/assertions.js";
|
||||
import {
|
||||
InMemorySecretStore,
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
envSecretField,
|
||||
} from "@inspector/core/auth/node/secret-store.js";
|
||||
|
||||
describe("summarizeServerConfig", () => {
|
||||
it("summarises stdio, sse, and streamable-http configs", () => {
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
}),
|
||||
).toEqual({ type: "stdio", detail: "node server.js" });
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
}),
|
||||
).toEqual({ type: "stdio", detail: "node" });
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "sse",
|
||||
url: "http://localhost/sse",
|
||||
}),
|
||||
).toEqual({ type: "sse", detail: "http://localhost/sse" });
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "streamable-http",
|
||||
url: "http://localhost/mcp",
|
||||
} as MCPServerConfig),
|
||||
).toEqual({ type: "streamable-http", detail: "http://localhost/mcp" });
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "streamable-http",
|
||||
} as MCPServerConfig),
|
||||
).toEqual({ type: "streamable-http", detail: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("annotateServerEntriesWithSessions", () => {
|
||||
const entries = [
|
||||
{ name: "a", type: "stdio", detail: "node a" },
|
||||
{ name: "b", type: "stdio", detail: "node b" },
|
||||
];
|
||||
|
||||
it("returns entries unchanged when there are no sessions", () => {
|
||||
expect(annotateServerEntriesWithSessions(entries, [])).toBe(entries);
|
||||
});
|
||||
|
||||
it("marks matching entry names and MRU", () => {
|
||||
expect(
|
||||
annotateServerEntriesWithSessions(entries, [
|
||||
{ name: "b", isMru: true },
|
||||
{ name: "other" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ name: "a", type: "stdio", detail: "node a" },
|
||||
{ name: "b", type: "stdio", detail: "node b", session: "b", isMru: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits isMru when the session is not MRU", () => {
|
||||
expect(
|
||||
annotateServerEntriesWithSessions(entries, [{ name: "a", isMru: false }]),
|
||||
).toEqual([
|
||||
{ name: "a", type: "stdio", detail: "node a", session: "a" },
|
||||
{ name: "b", type: "stdio", detail: "node b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listServerEntries / --method servers/list", () => {
|
||||
let configPath: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (configPath) {
|
||||
deleteConfigFile(configPath);
|
||||
configPath = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("lists catalog entries without connecting", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const entries = await listServerEntries({ configPath });
|
||||
expect(entries.map((e) => e.name).sort()).toEqual([
|
||||
"test-http",
|
||||
"test-stdio",
|
||||
]);
|
||||
expect(entries.find((e) => e.name === "test-stdio")?.type).toBe("stdio");
|
||||
});
|
||||
|
||||
it("works via --method servers/list", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--method",
|
||||
"servers/list",
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
expectCliSuccess(result);
|
||||
const body = JSON.parse(result.stdout) as {
|
||||
result: { servers: { name: string }[] };
|
||||
};
|
||||
expect(body.result.servers.map((s) => s.name).sort()).toEqual([
|
||||
"test-http",
|
||||
"test-stdio",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showServerEntry / servers/show", () => {
|
||||
let configPath: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (configPath) {
|
||||
deleteConfigFile(configPath);
|
||||
configPath = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts env values, init headers, and sensitive settings fields", () => {
|
||||
expect(
|
||||
sanitizeServerConfig({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
env: { SECRET: "value", HELLO: "world" },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
env: { SECRET: "[redacted]", HELLO: "[redacted]" },
|
||||
});
|
||||
|
||||
expect(
|
||||
sanitizeServerConfig({
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
Cookie: "session=abc",
|
||||
"X-Auth": "t",
|
||||
"Proxy-Authorization": "Basic x",
|
||||
"X-Custom": "ok",
|
||||
},
|
||||
},
|
||||
eventSourceInit: {
|
||||
headers: {
|
||||
"X-Api-Key": "k",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
},
|
||||
} as MCPServerConfig),
|
||||
).toMatchObject({
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: "[redacted]",
|
||||
Cookie: "[redacted]",
|
||||
"X-Auth": "[redacted]",
|
||||
"Proxy-Authorization": "[redacted]",
|
||||
"X-Custom": "ok",
|
||||
},
|
||||
},
|
||||
eventSourceInit: {
|
||||
headers: {
|
||||
"X-Api-Key": "[redacted]",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
sanitizeServerConfig({
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
requestInit: {
|
||||
headers: [
|
||||
["Authorization", "Bearer secret"],
|
||||
["X-Custom", "ok"],
|
||||
],
|
||||
},
|
||||
} as MCPServerConfig),
|
||||
).toMatchObject({
|
||||
requestInit: {
|
||||
headers: [
|
||||
["Authorization", "[redacted]"],
|
||||
["X-Custom", "ok"],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const settings: InspectorServerSettings = {
|
||||
headers: [
|
||||
{ key: "Authorization", value: "Bearer x" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
],
|
||||
metadata: [
|
||||
{ key: "Authorization", value: "Bearer meta" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
],
|
||||
env: [
|
||||
{ key: "TOKEN", value: "secret" },
|
||||
{ key: "", value: "still-secret" },
|
||||
],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60_000,
|
||||
maxFetchRequests: 100,
|
||||
roots: [],
|
||||
oauthClientSecret: "shh",
|
||||
oauthClientId: "cid",
|
||||
};
|
||||
const sanitized = sanitizeServerSettings(settings);
|
||||
expect(sanitized.oauthClientSecret).toBe("[redacted]");
|
||||
expect(sanitized.oauthClientId).toBe("cid");
|
||||
expect(sanitized.headers).toEqual([
|
||||
{ key: "Authorization", value: "[redacted]" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
]);
|
||||
expect(sanitized.metadata).toEqual([
|
||||
{ key: "Authorization", value: "[redacted]" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
]);
|
||||
expect(sanitized.env).toEqual([
|
||||
{ key: "TOKEN", value: "[redacted]" },
|
||||
{ key: "", value: "[redacted]" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows one resolved entry without connecting", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const entry = await showServerEntry("test-stdio", {
|
||||
configPath,
|
||||
secretStore: new InMemorySecretStore(),
|
||||
});
|
||||
expect(entry.name).toBe("test-stdio");
|
||||
expect(entry.type).toBe("stdio");
|
||||
expect(entry.config).toMatchObject({
|
||||
type: "stdio",
|
||||
command: expect.any(String),
|
||||
});
|
||||
expect(entry.config.env).toEqual({ HELLO: "[redacted]" });
|
||||
});
|
||||
|
||||
it("redacts secrets rehydrated from an injected secret store", async () => {
|
||||
configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
"secret-stdio": {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
env: { HELLO: "" },
|
||||
},
|
||||
"secret-http": {
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
// Disk keeps clientId only; secret lives in the store.
|
||||
oauth: { clientId: "cid" },
|
||||
} as MCPServerConfig,
|
||||
},
|
||||
});
|
||||
const secretStore = new InMemorySecretStore();
|
||||
await secretStore.set(
|
||||
"secret-stdio",
|
||||
envSecretField("HELLO"),
|
||||
"top-secret-env",
|
||||
);
|
||||
await secretStore.set(
|
||||
"secret-http",
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
"top-secret-oauth",
|
||||
);
|
||||
|
||||
const stdio = await showServerEntry("secret-stdio", {
|
||||
configPath,
|
||||
secretStore,
|
||||
});
|
||||
expect(stdio.config.env).toEqual({ HELLO: "[redacted]" });
|
||||
expect(JSON.stringify(stdio)).not.toContain("top-secret-env");
|
||||
|
||||
const http = await showServerEntry("secret-http", {
|
||||
configPath,
|
||||
secretStore,
|
||||
});
|
||||
expect(http.settings?.oauthClientSecret).toBe("[redacted]");
|
||||
expect(http.settings?.oauthClientId).toBe("cid");
|
||||
expect(JSON.stringify(http)).not.toContain("top-secret-oauth");
|
||||
});
|
||||
|
||||
it("works via --method servers/show --server", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--method",
|
||||
"servers/show",
|
||||
"--server",
|
||||
"test-http",
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
expectCliSuccess(result);
|
||||
const body = JSON.parse(result.stdout) as {
|
||||
result: { name: string; type: string };
|
||||
};
|
||||
expect(body.result.name).toBe("test-http");
|
||||
expect(body.result.type).toBe("streamable-http");
|
||||
});
|
||||
|
||||
it("rejects servers/show without --server", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--method",
|
||||
"servers/show",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/--server/);
|
||||
});
|
||||
|
||||
it("rejects unknown server names", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
await expect(
|
||||
showServerEntry("nope", {
|
||||
configPath,
|
||||
secretStore: new InMemorySecretStore(),
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
40
clients/cli/__tests__/style.test.ts
Normal file
40
clients/cli/__tests__/style.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createStyle,
|
||||
resolveAnsiEnabled,
|
||||
styleFromOpts,
|
||||
PLAIN,
|
||||
} from "../src/style.js";
|
||||
|
||||
describe("resolveAnsiEnabled", () => {
|
||||
it("is off for --plain, json, NO_COLOR, and non-TTY", () => {
|
||||
expect(resolveAnsiEnabled({ plain: true, isTTY: true })).toBe(false);
|
||||
expect(resolveAnsiEnabled({ format: "json", isTTY: true })).toBe(false);
|
||||
expect(resolveAnsiEnabled({ isTTY: true, noColorEnv: "1" })).toBe(false);
|
||||
expect(resolveAnsiEnabled({ isTTY: false, noColorEnv: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("is on for TTY text without plain/NO_COLOR", () => {
|
||||
expect(
|
||||
resolveAnsiEnabled({ isTTY: true, noColorEnv: "", format: "text" }),
|
||||
).toBe(true);
|
||||
expect(styleFromOpts({ isTTY: true, noColorEnv: "" }).ansi).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStyle", () => {
|
||||
it("PLAIN is identity; ansi wraps SGR and OSC 8", () => {
|
||||
expect(PLAIN.bold("x")).toBe("x");
|
||||
expect(PLAIN.link("https://example.com")).toBe("https://example.com");
|
||||
|
||||
const s = createStyle(true);
|
||||
expect(s.bold("x")).toContain("\u001b[1m");
|
||||
expect(s.dim("x")).toContain("\u001b[2m");
|
||||
expect(s.red("x")).toContain("\u001b[31m");
|
||||
expect(s.link("https://example.com", "ex")).toContain(
|
||||
"\u001b]8;;https://example.com",
|
||||
);
|
||||
expect(s.link("https://example.com", "ex")).toContain("ex");
|
||||
expect(s.link("", "ex")).toBe("ex");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue