diff --git a/.gitignore b/.gitignore index 1e686966..09f8427e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ test-servers/build /*.png /inspector-history-*.json /*.server.json +/configs/ diff --git a/AGENTS.md b/AGENTS.md index 81f28bae..8b6593b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,11 +67,10 @@ After installing, `npm run build` builds all clients. The launcher scripts (`npm ## Repository & Project Board - **Repo**: https://github.com/modelcontextprotocol/inspector.git -- **Base Branches**: v2/main, v1.5/main, main +- **Base Branches**: v2/main (active), main (v1). v1.5/main is merged into v2/main and no longer takes new work. - **Project Boards**: - - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 - - v1.5 - https://github.com/orgs/modelcontextprotocol/projects/39 - - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 + - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all current work goes here) + - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (existing inspector version, no new activity except security and bug fixes) ## Contributing @@ -83,11 +82,11 @@ diff. See [`CONTRIBUTORS.md`](./CONTRIBUTORS.md) for the full policy. ## Project Status and Direction * The main branch currently contains the legacy version of the Inspector, which we are accepting bug fixes and minor improvement PRs for. -* The v1.5/main branch contains an intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector are extracted into a core subsystem with InspectorClient class as the common entry point. It also includes the TUI, a refactored CLI, and streamlined launcher. +* The v1.5/main branch was the intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector was extracted into a core subsystem with InspectorClient class as the common entry point. It also included the TUI, a refactored CLI, and streamlined launcher. The branch still exists but is **frozen** — it takes no new work. It is kept as a reference point (e.g. for tracking down a regression introduced by the merge into v2/main), so do not delete it. -* The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic. +* The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic. -* The InspectorClient from v1.5/main will be merged into v2/main, and wired up to the new web Inspector. The TUI and CLI will follow. Eventually when everything works on v2/main we will replace main with v2/main, eliminating the legacy implementations. +The Launcher, TUI, CLI, and InspectorClient from v1.5/main have been merged into v2/main. InspectorClient is wired up to the new web Inspector. Eventually, we will replace main with v2/main, eliminating the legacy implementations. ## Web backend auth token @@ -111,20 +110,67 @@ Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the gl All work should be driven by items on the project board. +> **A v2 issue is not "created" until it is BOTH labeled `v2` AND on board #28 with a Status set.** Labeling alone is not enough — a label is a repo tag; the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status. All three are distinct steps; do all three (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. + - Before starting work, check the board for the relevant item. -- **Draft items vs. issues**: Board items may be draft items (no issue number) or full GitHub issues. Before creating a new issue, always check if a matching draft item already exists on the board. If it does, convert it to an issue using `gh project item-edit` or create the issue and link it — **never create a duplicate**. +- **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. +- **Assign the issue to its creator.** When you create an issue, assign it to the user it is created on behalf of (`gh issue create --assignee @me ...`, or `--assignee `). Board items should never be unassigned. - **Label by version.** New issues and PRs must carry the label matching the target board / branch: - `main` → `v1` - - `v1.5/main` → `v1.5` - `v2/main` → `v2` Set the label at create time (`gh issue create --label v2 ...`, `gh pr create --label v2 ...`) — don't rely on backfilling later, since unlabeled PRs are easy to miss when filtering by version. -- When work begins, create a feature branch and move the item to "In Progress". +- **Add the issue to the board and set Status.** After creating an issue, add it to board #28 and set its Status. (PRs are never added to the board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim. +- When work begins, create a feature branch and set the item's Status to **In progress** (or one of the building statuses below). - When work is complete: - Run format, lint, typecheck, build, and test — ensure all checks pass - - Open a PR against the matching base branch (`main` for v1, `v1.5/main` for v1.5, `v2/main` for v2) and move the item to "In Review" + - Open a PR against the matching base branch (`main` for v1, `v2/main` for v2) and set the item's Status to **In review** + - **Link the PR to its issue.** The PR body's **first line must be `Closes #`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) + - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `1bbc5632`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. - If new tasks are discovered or requested during development, create issues and add them to the board. +#### V2 board (#28) `gh` recipes + +The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The IDs below are stable; if a command rejects one, re-fetch with `gh project field-list 28 --owner modelcontextprotocol --format json`. + +| Thing | ID | +| --- | --- | +| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | +| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | + +Status option IDs (`--single-select-option-id`): + +| Status | Option ID | +| --- | --- | +| Backlog | `6080ca99` | +| Building CLI / TUI / CORE | `fe170c62` | +| Building Web | `4faeae7a` | +| MCP Apps Extension | `588c6a63` | +| In progress | `d43284fe` | +| In review | `fb2103f2` | +| Done | `1bbc5632` | + +Use **In progress** for general work, one of the **Building** statuses (or **MCP Apps Extension**) while actively coding that surface, **In review** once a PR is open, and **Done** on merge. + +```sh +# 1. Add an issue to the board — prints the item id (PVTI_…); capture it. +gh project item-add 28 --owner modelcontextprotocol --url --format json + +# 2. Set its Status (here: In progress). Use the option id from the table above. +gh project item-edit \ + --project-id PVT_kwDOCt2Azc4BJVxt \ + --id \ + --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c \ + --single-select-option-id d43284fe +``` + +The one-liner that does both, capturing the item id (use the option id for the status you want): + +```sh +ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url --format json --jq '.id') +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id d43284fe +``` + ### Always test new or modified code - Ensure all code has corresponding tests - Ensure test coverage for each file is at least 90% @@ -138,8 +184,8 @@ All work should be driven by items on the project board. - The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web: - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. -- Run `npm run test:integration` (also from `clients/web/`) for the v1.5-ported InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI runs it as its own step after unit tests. -- Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). v1.5-ported integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. +- Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI runs it as its own step after unit tests. +- Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. - Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme ### Responding to Code Reviews diff --git a/clients/cli/README.md b/clients/cli/README.md index 8f7c41d8..ce03c465 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -100,6 +100,49 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--metadata ` | General metadata (key=value); applied to all methods. | | `--tool-metadata ` | Tool-specific metadata for `tools/call`. | +### CLI-specific (OAuth for HTTP servers) + +The CLI **reuses** OAuth tokens from `~/.mcp-inspector/storage/oauth.json` (same file as the TUI). Complete first-time authorization in the **web** or **TUI** client, then run one-shot CLI commands against HTTP/SSE servers without signing in again. + +The CLI does **not** start a local callback server or retry connect on 401. If tokens are missing or expired, connect fails; `ConsoleNavigation` may print an authorize URL to stdout, but the CLI cannot finish the redirect flow. Use the TUI for interactive runner OAuth until Phase 4 adds a CLI callback server. + +**Shared with TUI** (config only, not interactive login): + +- Per-server OAuth fields from `mcp.json` (static client, EMA resource credentials, scopes) +- Install-level settings from **`~/.mcp-inspector/storage/client.json`** (or `--client-config` / `MCP_CLIENT_CONFIG_PATH`) — EMA IdP, CIMD +- CLI flags `--client-id`, `--client-secret`, `--client-metadata-url` override `client.json` when set +- Keychain-backed secrets in `mcp.json` are rehydrated on catalog load (same as TUI) + +#### OAuth callback URL + +| Surface | Default callback | +| ------- | ---------------- | +| **Web** | `http://localhost:6274/oauth/callback` | +| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) | +| **CLI** | `http://127.0.0.1:6276/oauth/callback` (redirect URI in OAuth metadata only; no listener) | + +Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** (or when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port. + +#### Flags + +| Option | Env | Description | +| ------ | --- | ----------- | +| `--client-config ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | +| `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | +| `--client-secret ` | — | OAuth client secret; overrides `client.json`. | +| `--client-metadata-url ` | — | CIMD metadata URL; overrides `client.json`. | +| `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). | + +**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line: + +```bash +npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-server \ + --client-metadata-url https://example.com/.well-known/oauth/client-metadata.json \ + --method tools/list +``` + +See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for configuration details and staging servers. + ## Why use the CLI? While the Web Client provides a rich visual interface, the CLI is designed for: diff --git a/clients/cli/__tests__/helpers/fixtures.ts b/clients/cli/__tests__/helpers/fixtures.ts index bfae186a..457986a5 100644 --- a/clients/cli/__tests__/helpers/fixtures.ts +++ b/clients/cli/__tests__/helpers/fixtures.ts @@ -88,3 +88,22 @@ export function createInvalidConfig(): string { export function deleteConfigFile(configPath: string): void { cleanupTempDir(path.dirname(configPath)); } + +/** + * Create a temporary install-level client.json for --client-config tests. + */ +export function createClientConfigFile( + config: Record, +): string { + const tempDir = createTempDir("mcp-inspector-client-config-"); + const configPath = path.join(tempDir, "client.json"); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + return configPath; +} + +/** + * Delete a client config file and its containing directory + */ +export function deleteClientConfigFile(configPath: string): void { + cleanupTempDir(path.dirname(configPath)); +} diff --git a/clients/cli/__tests__/oauth-runner.test.ts b/clients/cli/__tests__/oauth-runner.test.ts new file mode 100644 index 00000000..180b29b5 --- /dev/null +++ b/clients/cli/__tests__/oauth-runner.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import * as runner from "@inspector/core/client/runner.js"; +import { + createTestServerHttp, + createEchoTool, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import { runCli } from "./helpers/cli-runner.js"; +import { + expectCliFailure, + expectCliSuccess, + expectOutputContains, +} from "./helpers/assertions.js"; +import { + createClientConfigFile, + deleteClientConfigFile, +} from "./helpers/fixtures.js"; + +/** + * Covers OAuth runner flag wiring in `src/cli.ts` (#1514): --client-config, + * --client-id/--client-secret/--client-metadata-url, and --callback-url / + * MCP_OAUTH_CALLBACK_URL. Shared auth logic is unit-tested in + * clients/web/src/test/core/client/runner.test.ts; these assert the CLI + * parses flags and passes them through on HTTP (OAuth-capable) connects. + */ +describe("CLI OAuth runner flags", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("connects over HTTP with OAuth CLI overrides and custom callback URL", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions"); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--client-id", + "test-client-id", + "--client-secret", + "test-client-secret", + "--client-metadata-url", + "https://example.com/oauth/client-metadata.json", + "--callback-url", + "http://127.0.0.1:9999/oauth/callback", + ]); + + expectCliSuccess(result); + expect(buildSpy).toHaveBeenCalled(); + expect(buildSpy.mock.calls[0]?.[2]).toEqual({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + clientMetadataUrl: "https://example.com/oauth/client-metadata.json", + }); + } finally { + await server.stop(); + } + }); + + it("loads install client.json from --client-config and prefers CLI CIMD override", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions"); + const clientConfigPath = createClientConfigFile({ + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/from-client-json.json", + }, + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--client-config", + clientConfigPath, + "--client-metadata-url", + "https://example.com/from-cli-flag.json", + ]); + + expectCliSuccess(result); + expect(buildSpy).toHaveBeenCalled(); + expect(buildSpy.mock.calls[0]?.[0]).toMatchObject({ + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/from-client-json.json", + }, + }); + expect(buildSpy.mock.calls[0]?.[2]).toEqual({ + clientMetadataUrl: "https://example.com/from-cli-flag.json", + }); + } finally { + await server.stop(); + deleteClientConfigFile(clientConfigPath); + } + }); + + it("accepts port 0 callback URL for ephemeral listener binding", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--callback-url", + "http://127.0.0.1:0/oauth/callback", + ]); + + expectCliSuccess(result); + } finally { + await server.stop(); + } + }); + + it("uses MCP_OAUTH_CALLBACK_URL when --callback-url is absent", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli( + [server.url, "--cli", "--method", "tools/list", "--transport", "http"], + { + env: { + MCP_OAUTH_CALLBACK_URL: + "http://127.0.0.1:8888/custom/oauth/callback", + }, + }, + ); + + expectCliSuccess(result); + } finally { + await server.stop(); + } + }); + + it("rejects an invalid OAuth callback URL before connecting", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--callback-url", + "not-a-valid-url", + ]); + + expectCliFailure(result); + expectOutputContains(result, "Invalid OAuth callback URL"); + } finally { + await server.stop(); + } + }); +}); diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 4514ee2a..940cfb55 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", @@ -907,6 +908,225 @@ } } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index 0175b69a..d76b3b45 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 3bb6fedf..a8039cbe 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -6,6 +6,7 @@ import { awaitableLog } from "./utils/awaitable-log.js"; import type { InspectorServerSettings, MCPServerConfig, + InspectorClientEnvironment, } from "@inspector/core/mcp/types.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { @@ -22,6 +23,24 @@ import { parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; +import { + ConsoleNavigation, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/index.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, + type RunnerOAuthCallbackConfig, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; +import type { ClientConfig } from "@inspector/core/client/types.js"; +import { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, + loadRunnerClientConfig, + type RunnerClientConfigOverrides, +} from "@inspector/core/client/runner.js"; import { LoggingLevelSchema, type LoggingLevel, @@ -47,6 +66,9 @@ async function callMethod( serverConfig: MCPServerConfig, serverSettings: InspectorServerSettings | undefined, args: MethodArgs & { method: string }, + clientConfig: ClientConfig, + cliAuthOverrides?: RunnerClientConfigOverrides, + callbackUrlConfig?: RunnerOAuthCallbackConfig, ): Promise { const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, "../package.json"); @@ -62,16 +84,36 @@ async function callMethod( const version = packageJson.version; const clientIdentity = { name, version }; + const environment: InspectorClientEnvironment = { + transport: createTransportNode, + }; + const redirectUrlProvider = new MutableRedirectUrlProvider(); + if (isOAuthCapableServerConfig(serverConfig)) { + redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl( + callbackUrlConfig!, + ); + environment.oauth = { + storage: new NodeOAuthStorage(), + navigation: new ConsoleNavigation(), + redirectUrlProvider, + }; + } + + const clientAuthOptions = buildRunnerClientAuthOptions( + clientConfig, + serverSettings, + cliAuthOverrides, + ); + const inspectorClient = new InspectorClient(serverConfig, { - environment: { - transport: createTransportNode, - }, + environment, clientIdentity, initialLoggingLevel: "debug", progress: false, sample: false, elicit: false, serverSettings, + ...clientAuthOptions, }); let managedToolsState: ManagedToolsState | null = null; @@ -240,11 +282,16 @@ function parseKeyValuePair( return { ...previous, [key as string]: parsedValue }; } -function parseArgs(argv?: string[]): { +async function parseArgs(argv?: string[]): Promise<{ serverConfig: MCPServerConfig; serverSettings: InspectorServerSettings | undefined; methodArgs: MethodArgs & { method: string }; -} { + clientConfigPath?: string; + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + callbackUrl?: string; +}> { const program = new Command(); // On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead // of letting commander call process.exit(). The binary entry (index.ts) still @@ -368,6 +415,26 @@ function parseArgs(argv?: string[]): { "Tool-specific metadata as key=value pairs (for tools/call method only)", parseKeyValuePair, {}, + ) + .option( + "--client-config ", + "Install-level client config (default: ~/.mcp-inspector/storage/client.json, or MCP_CLIENT_CONFIG_PATH)", + ) + .option( + "--client-id ", + "OAuth client ID (static client) for HTTP servers", + ) + .option( + "--client-secret ", + "OAuth client secret (for confidential clients)", + ) + .option( + "--client-metadata-url ", + "OAuth Client ID Metadata Document URL (CIMD) for HTTP servers", + ) + .option( + "--callback-url ", + `OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`, ); program.parse(preArgs); @@ -390,6 +457,11 @@ function parseArgs(argv?: string[]): { transport?: "sse" | "http" | "stdio"; serverUrl?: string; header?: Record; + clientConfig?: string; + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + callbackUrl?: string; }; const serverOptions = { @@ -410,7 +482,7 @@ function parseArgs(argv?: string[]): { // Shared with the TUI: resolves the catalog/config source (or ad-hoc target), // enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into // per-server settings. `--server` selects one when the file has several. - const entries = loadServerEntries(serverOptions); + const entries = await loadServerEntries(serverOptions); const { config: serverConfig, settings: serverSettings } = selectServerEntry( entries, options.server, @@ -452,12 +524,37 @@ function parseArgs(argv?: string[]): { serverConfig, serverSettings, methodArgs, + clientConfigPath: options.clientConfig, + clientId: options.clientId, + clientSecret: options.clientSecret, + clientMetadataUrl: options.clientMetadataUrl, + callbackUrl: options.callbackUrl, }; } export async function runCli(argv?: string[]): Promise { - const { serverConfig, serverSettings, methodArgs } = parseArgs( - argv ?? process.argv, + const { + serverConfig, + serverSettings, + methodArgs, + clientConfigPath, + clientId, + clientSecret, + clientMetadataUrl, + callbackUrl, + } = await parseArgs(argv ?? process.argv); + const clientConfig = await loadRunnerClientConfig({ clientConfigPath }); + const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl); + await callMethod( + serverConfig, + serverSettings, + methodArgs, + clientConfig, + { + clientId, + clientSecret, + clientMetadataUrl, + }, + callbackUrlConfig, ); - await callMethod(serverConfig, serverSettings, methodArgs); } diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index 1361090e..0091499c 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ platform: 'node', // Bundle core source; leave npm deps external. noExternal: [/^@inspector\/core/], + external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino', 'zustand'], esbuildOptions(options) { options.alias = { '@inspector/core': path.join(repoRoot, 'core'), diff --git a/clients/tui/README.md b/clients/tui/README.md index 4ca7af49..28b0a745 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -33,14 +33,41 @@ Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, ### TUI-specific (OAuth for HTTP servers) -When connecting to SSE or Streamable HTTP servers that use OAuth, you can pass: +The TUI supports OAuth for **SSE** and **Streamable HTTP** servers. Per-server OAuth fields in `mcp.json` (static client id/secret, scopes, enterprise-managed flag) are applied automatically when loaded from `--catalog` or `--config`. Install-wide settings (CIMD, enterprise IdP) come from **`~/.mcp-inspector/storage/client.json`** — the same file the web **Client Settings** dialog writes. You can point at a different file with `--client-config` or `MCP_CLIENT_CONFIG_PATH`. -| Option | Description | -| ----------------------------- | ------------------------------------------------------------------------------------ | -| `--client-id ` | OAuth client ID (static client). | -| `--client-secret ` | OAuth client secret (confidential clients). | -| `--client-metadata-url ` | OAuth Client ID Metadata Document URL (CIMD). | -| `--callback-url ` | OAuth redirect/callback listener URL (default: `http://127.0.0.1:0/oauth/callback`). | +#### OAuth callback URL + +The TUI starts a small loopback HTTP server to receive the authorization redirect after you sign in in the browser. Defaults: + +| Surface | Default callback | +| ------- | ---------------- | +| **Web** | `http://localhost:6274/oauth/callback` (main app server) | +| **TUI** | `http://127.0.0.1:6276/oauth/callback` (dedicated runner port; avoids colliding with web on 6274) | + +**Why a fixed default port?** Enterprise-managed auth (EMA), CIMD, and many static OAuth apps require **pre-registered redirect URIs**. A predictable default (`http://127.0.0.1:6276/oauth/callback`) lets you register once on the IdP and reuse it across TUI sessions. Dynamic registration (DCR) can use ephemeral ports instead — see below. + +**Trade-off:** only **one TUI OAuth flow at a time** can listen on the default port. A second instance starting OAuth while another is in progress may fail with `EADDRINUSE`. Override the listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL` (e.g. a different fixed port per instance, or `http://127.0.0.1:0/oauth/callback` when the authorization server accepts dynamically registered redirect URIs). + +OAuth redirect URIs must match **exactly** what you register on the authorization server — `localhost` and `127.0.0.1` are different URIs. Register the TUI default on your OAuth app / IdP when using pre-registered (static), CIMD, or enterprise-managed clients. Override the listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`; use `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port when the authorization server registers redirect URIs dynamically (DCR). + +#### Flags + +| Option | Env | Description | +| ------ | --- | ----------- | +| `--client-config ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | +| `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | +| `--client-secret ` | — | OAuth client secret (confidential clients); overrides `client.json`. | +| `--client-metadata-url ` | — | Client ID Metadata Document URL (CIMD); overrides `client.json`. | +| `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | OAuth redirect/callback listener (default: `http://127.0.0.1:6276/oauth/callback`). | + +#### Authenticating in the TUI + +1. Select an HTTP/SSE server and press **C** to connect. +2. If authorization is required, the TUI starts OAuth automatically (browser opens for sign-in). +3. After the callback completes, connect finishes without a second **C**. +4. Use the **Auth** tab to inspect OAuth state (same fields as web Connection Info) or **Clear OAuth state** (disconnects when connected). + +See also [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for staging servers and verification steps. ## Features diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx new file mode 100644 index 00000000..ddefbecc --- /dev/null +++ b/clients/tui/__tests__/App.test.tsx @@ -0,0 +1,939 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "ink-testing-library"; + +type RenderResult = ReturnType; + +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); +vi.mock("ink-form", () => import("./helpers/inkFormMock.js")); + +// --------------------------------------------------------------------------- +// Controllable mock of the entire @inspector/core surface App.tsx depends on. +// `ctrl` is mutated by individual tests (reset in beforeEach) to drive what the +// hooks return and what the InspectorClient methods do. +// --------------------------------------------------------------------------- +const h = vi.hoisted(() => { + interface Ctrl { + status: string; + capabilities: Record | null; + serverInfo: { name?: string; version?: string } | null; + instructions: string | null; + serverType: "stdio" | "sse" | "streamable-http"; + oauthFlowState: unknown; + tools: unknown[]; + resources: unknown[]; + resourceTemplates: unknown[]; + prompts: unknown[]; + messages: unknown[]; + fetchRequests: unknown[]; + stderrLogs: unknown[]; + } + const ctrl: Ctrl = { + status: "disconnected", + capabilities: null, + serverInfo: null, + instructions: null, + serverType: "stdio", + oauthFlowState: null, + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + messages: [], + fetchRequests: [], + stderrLogs: [], + }; + const connect = vi.fn().mockResolvedValue(undefined); + const disconnect = vi.fn().mockResolvedValue(undefined); + const openUrl = vi.fn().mockResolvedValue(undefined); + // Shared OAuth-related spies so a test can configure resolve/reject and + // assert calls regardless of which per-server FakeClient instance App built. + const clientSpies = { + authenticate: vi.fn( + async (): Promise => "https://auth.example/start", + ), + clearOAuthTokens: vi.fn(), + completeOAuthFlow: vi.fn(async (): Promise => {}), + getOAuthState: vi.fn(async () => undefined), + }; + // Captured options from the most recent callbackServer.start(), so a test can + // drive the onCallback / onError handlers the OAuth flows register. + interface CallbackOpts { + onCallback: (p: { code: string }) => Promise | void; + onError: (p: { error?: string; error_description?: string }) => void; + } + const cb: { opts: CallbackOpts | null } = { opts: null }; + const callbackStart = vi.fn(async (opts: CallbackOpts) => { + cb.opts = opts; + return { redirectUrl: "http://localhost/cb" }; + }); + const callbackStop = vi.fn().mockResolvedValue(undefined); + const createOAuthCallbackServer = vi.fn(() => ({ + start: callbackStart, + stop: callbackStop, + })); + class FakeManager { + destroy = vi.fn(); + } + class FakeClient { + cfg: { type?: string } | undefined; + constructor(config?: { type?: string }) { + this.cfg = config; + } + // Derive the transport type from the server config the client was built + // with (config.type aligns with the serverType union) so per-server gating + // (logging/requests tabs) works in mixed catalogs; fall back to ctrl. + getServerType = vi.fn( + () => + (this.cfg?.type ?? ctrl.serverType) as + | "stdio" + | "sse" + | "streamable-http", + ); + authenticate = (...a: unknown[]) => clientSpies.authenticate(...a); + clearOAuthTokens = (...a: unknown[]) => clientSpies.clearOAuthTokens(...a); + completeOAuthFlow = (...a: unknown[]) => + clientSpies.completeOAuthFlow(...a); + getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a); + readResource = vi.fn(async () => ({ + result: { contents: [{ uri: "file://x", text: "hello" }] }, + })); + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + // Reject so the unmount cleanup's `.catch(() => {})` arrow is exercised. + disconnect = vi.fn().mockRejectedValue(new Error("cleanup disconnect")); + } + return { + ctrl, + connect, + disconnect, + openUrl, + clientSpies, + cb, + createOAuthCallbackServer, + callbackStart, + callbackStop, + FakeManager, + FakeClient, + useInspectorClient: vi.fn(() => ({ + status: ctrl.status, + capabilities: ctrl.capabilities, + serverInfo: ctrl.serverInfo, + instructions: ctrl.instructions, + connect, + disconnect, + })), + useManagedTools: vi.fn(() => ({ tools: ctrl.tools })), + useManagedResources: vi.fn(() => ({ resources: ctrl.resources })), + useManagedResourceTemplates: vi.fn(() => ({ + resourceTemplates: ctrl.resourceTemplates, + })), + useManagedPrompts: vi.fn(() => ({ prompts: ctrl.prompts })), + useMessageLog: vi.fn(() => ({ messages: ctrl.messages })), + useFetchRequestLog: vi.fn(() => ({ fetchRequests: ctrl.fetchRequests })), + useStderrLog: vi.fn(() => ({ stderrLogs: ctrl.stderrLogs })), + }; +}); + +vi.mock("@inspector/core/mcp/index.js", () => ({ + InspectorClient: h.FakeClient, +})); +vi.mock("@inspector/core/mcp/state/index.js", () => ({ + ManagedToolsState: h.FakeManager, + ManagedResourcesState: h.FakeManager, + ManagedResourceTemplatesState: h.FakeManager, + ManagedPromptsState: h.FakeManager, + MessageLogState: h.FakeManager, + FetchRequestLogState: h.FakeManager, + StderrLogState: h.FakeManager, +})); +vi.mock("@inspector/core/mcp/node/index.js", () => ({ + createTransportNode: vi.fn(), +})); +vi.mock("@inspector/core/react/useInspectorClient.js", () => ({ + useInspectorClient: h.useInspectorClient, +})); +vi.mock("@inspector/core/react/useManagedTools.js", () => ({ + useManagedTools: h.useManagedTools, +})); +vi.mock("@inspector/core/react/useManagedResources.js", () => ({ + useManagedResources: h.useManagedResources, +})); +vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({ + useManagedResourceTemplates: h.useManagedResourceTemplates, +})); +vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({ + useManagedPrompts: h.useManagedPrompts, +})); +vi.mock("@inspector/core/react/useMessageLog.js", () => ({ + useMessageLog: h.useMessageLog, +})); +vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({ + useFetchRequestLog: h.useFetchRequestLog, +})); +vi.mock("@inspector/core/react/useStderrLog.js", () => ({ + useStderrLog: h.useStderrLog, +})); +vi.mock("@inspector/core/auth/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + CallbackNavigation: class {}, + MutableRedirectUrlProvider: class { + redirectUrl = ""; + }, + }; +}); +vi.mock("@inspector/core/auth/node/index.js", () => ({ + createOAuthCallbackServer: h.createOAuthCallbackServer, + NodeOAuthStorage: class {}, +})); +vi.mock("../src/utils/openUrl.js", () => ({ + openUrl: h.openUrl, +})); + +import App from "../src/App.js"; +import type { TuiServer } from "../src/tui-servers.js"; + +const tick = () => new Promise((r) => setTimeout(r, 25)); +const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" }; +const emptyClientConfig = {}; + +function stdioServer(): Record { + return { + alpha: { + config: { type: "stdio", command: "node", args: ["s.js"] }, + } as never, + beta: { + config: { type: "stdio", command: "node", args: ["b.js"] }, + } as never, + }; +} + +function httpServer(): Record { + return { + web: { config: { type: "streamable-http", url: "http://x" } } as never, + }; +} + +// Single-server catalogs auto-select their only server on mount, so action +// tests can drive accelerators without first navigating the server list. +function oneStdio(): Record { + return { + alpha: { + config: { type: "stdio", command: "node", args: ["s.js"] }, + } as never, + }; +} + +// Single streamable-http server catalog (auto-selected on mount). +function oneHttp(): Record { + return { + web: { config: { type: "streamable-http", url: "http://x" } } as never, + }; +} + +// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by +// a stdio server � drives per-server tab gating + the tab-switch-away effects. +function httpThenStdio(): Record { + return { + web: { config: { type: "streamable-http", url: "http://x" } } as never, + cli: { + config: { type: "stdio", command: "node", args: ["s.js"] }, + } as never, + }; +} + +function stdioThenHttp(): Record { + return { + cli: { + config: { type: "stdio", command: "node", args: ["s.js"] }, + } as never, + web: { config: { type: "streamable-http", url: "http://x" } } as never, + }; +} + +// An http server carrying saved settings (metadata, oauth creds, timeout) to +// exercise the per-server option-building branches in the mount effect. +function httpWithSettings(): Record { + return { + web: { + config: { type: "streamable-http", url: "http://x" }, + settings: { + requestTimeout: 5000, + metadata: [ + { key: "team", value: "alpha" }, + { key: " ", value: "ignored" }, + ], + oauthClientId: "cid", + oauthClientSecret: "secret", + oauthScopes: "read write", + }, + } as never, + }; +} + +// Realistic minimal fixtures for tab content / details modals. +const sampleTool = { + name: "alpha", + description: "Tool desc line1\nline2", + inputSchema: { type: "object", properties: { x: { type: "string" } } }, +}; +const sampleResource = { + name: "res1", + uri: "file://x", + description: "rdesc", + mimeType: "text/plain", +}; +const sampleTemplate = { + name: "tmpl1", + uriTemplate: "file://{id}", + description: "tdesc", +}; +const promptWithArgs = { + name: "p1", + description: "pdesc", + arguments: [{ name: "arg1", description: "a1" }], +}; +const promptNoName = { name: "", description: "no name prompt" }; +const reqMessage = { + id: "m1", + direction: "request", + message: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + response: { jsonrpc: "2.0", id: 1, result: {} }, + timestamp: new Date(0), + duration: 5, +}; +const notifMessage = { + id: "m2", + direction: "notification", + message: { jsonrpc: "2.0", method: "notifications/message" }, + timestamp: new Date(0), +}; +const fullRequest = { + id: "r1", + method: "POST", + url: "http://x/mcp", + category: "transport", + responseStatus: 200, + responseStatusText: "OK", + duration: 12, + timestamp: new Date(0), + requestHeaders: { "content-type": "application/json" }, + requestBody: JSON.stringify({ a: 1 }), + responseHeaders: { "x-h": "v" }, + responseBody: JSON.stringify({ ok: true }), +}; +const errorRequest = { + id: "r2", + method: "GET", + url: "http://x/auth", + category: "auth", + error: "boom", + timestamp: new Date(0), + requestHeaders: { accept: "*/*" }, + requestBody: "not json{", + responseBody: "also not json{", +}; +const respMessage = { + id: "m3", + direction: "response", + message: { jsonrpc: "2.0", id: 2, result: { ok: true } }, + timestamp: new Date(0), +}; +const bareRequest = { + id: "r3", + method: "GET", + url: "http://x/idle", + category: "transport", + timestamp: new Date(0), + requestHeaders: {}, +}; +const stderrLog = { timestamp: new Date(0), message: "log line" }; + +// Track rendered instances so each is unmounted after its test � concurrent +// mounted ink apps share raw-mode stdin handling and interfere with useInput. +const mounted: RenderResult[] = []; + +function renderApp(servers: Record) { + const r = render( + , + ); + mounted.push(r); + return r; +} + +/** + * Render and absorb ink-testing-library's intermittently-dropped first + * keypress with a benign no-op key ("x" is not bound while the server list is + * focused), so subsequent navigation keys register deterministically. + */ +async function mount(servers: Record) { + const r = renderApp(servers); + await tick(); + r.stdin.write("x"); + await tick(); + return r; +} + +// Arrow / shift-tab only parse as those keys when ESC-prefixed (a bare "[B" is +// read as the literal characters). Tab and Enter are real control characters. +const ESC = String.fromCharCode(27); +const DOWN = `${ESC}[B`; +const UP = `${ESC}[A`; +const RIGHT = `${ESC}[C`; +const LEFT = `${ESC}[D`; +const STAB = `${ESC}[Z`; +const TAB = "\t"; +const ENTER = "\r"; + +/** + * Write each key in order. ink re-subscribes the active component's useInput on + * re-render, so a key that changes focus/tab must let that re-render flush + * before the next key � otherwise the next key is routed to the old handler. + * Two ticks per key keeps multi-step navigation deterministic under the heavier + * v8 coverage instrumentation (where a single tick can race the render). + */ +async function press(r: RenderResult, keys: string[]) { + for (const k of keys) { + r.stdin.write(k); + await tick(); + await tick(); + } +} + +/** + * Poll until `predicate` is true (or the tries run out). React + ink schedule + * renders across several macrotasks, so async state set by a flow can take more + * than one fixed tick to land under coverage instrumentation. + */ +async function waitUntil(predicate: () => boolean, tries = 25) { + for (let i = 0; i < tries; i++) { + if (predicate()) return; + await tick(); + } +} + +/** + * Poll the frame until it contains `substr` (or the tries run out). Render + * settling races a single fixed tick under v8 coverage instrumentation, so + * frame assertions that follow a mount/keypress use this instead of one tick. + */ +async function waitForFrame(r: RenderResult, substr: string, tries = 25) { + await waitUntil(() => (r.lastFrame() ?? "").includes(substr), tries); +} + +/** Poll until the frame contains `substr`, then assert it � stable under load. */ +async function expectFrame(r: RenderResult, substr: string) { + await waitForFrame(r, substr); + expect(r.lastFrame() ?? "").toContain(substr); +} + +beforeEach(() => { + Object.assign(h.ctrl, { + status: "disconnected", + capabilities: null, + serverInfo: null, + instructions: null, + serverType: "stdio", + oauthFlowState: null, + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + messages: [], + fetchRequests: [], + stderrLogs: [], + }); + h.connect.mockClear(); + h.connect.mockResolvedValue(undefined); + h.disconnect.mockClear(); + h.disconnect.mockResolvedValue(undefined); + h.openUrl.mockClear(); + h.openUrl.mockResolvedValue(undefined); + h.cb.opts = null; + h.callbackStart.mockClear(); + h.callbackStop.mockClear(); + h.clientSpies.authenticate.mockReset(); + h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start"); + h.clientSpies.clearOAuthTokens.mockReset(); + h.clientSpies.completeOAuthFlow.mockReset(); + h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined); + h.clientSpies.getOAuthState.mockReset(); + h.clientSpies.getOAuthState.mockResolvedValue(undefined); +}); + +afterEach(() => { + while (mounted.length) mounted.pop()?.unmount(); +}); + +describe("App (foundation)", () => { + it("renders the server list with the MCP Servers header", async () => { + const r = renderApp(stdioServer()); + await expectFrame(r, "MCP Servers"); + const frame = r.lastFrame() ?? ""; + expect(frame).toContain("alpha"); + expect(frame).toContain("beta"); + }); + + it("auto-selects the first server and shows its config", async () => { + const r = renderApp(stdioServer()); + await expectFrame(r, "Server Configuration"); + }); + + it("moves selection down to the next server with the down arrow", async () => { + const r = await mount(stdioServer()); + await press(r, [DOWN]); // alpha -> beta + await expectFrame(r, "b.js"); + }); + + it("connects with 'c' when disconnected", async () => { + const { stdin } = await mount(oneStdio()); + stdin.write("c"); + await tick(); + expect(h.connect).toHaveBeenCalled(); + }); + + it("disconnects with 'd' when connected", async () => { + h.ctrl.status = "connected"; + const { stdin } = await mount(oneStdio()); + stdin.write("d"); + await tick(); + expect(h.disconnect).toHaveBeenCalled(); + }); + + it("switches tabs via accelerator keys", async () => { + const r = await mount(stdioServer()); + await press(r, ["t"]); // tools tab (server is auto-selected) + await expectFrame(r, "Tools"); + }); + + it("cycles focus with tab and shift+tab", async () => { + const { stdin } = renderApp(stdioServer()); + await tick(); + stdin.write("[B"); + await tick(); + stdin.write("\t"); // forward + await tick(); + // shift+tab is delivered as ESC [ Z + stdin.write(""); + await tick(); + // no assertion on hidden focus state � exercising the branches + }); + + it("shows the Auth tab via the accelerator for an OAuth-capable server", async () => { + h.ctrl.serverType = "streamable-http"; + const r = await mount(httpServer()); + await press(r, ["a"]); + await expectFrame(r, "OAuth"); + }); + + it("renders connected status with capabilities", async () => { + h.ctrl.status = "connected"; + h.ctrl.capabilities = { tools: {}, resources: {}, prompts: {} }; + h.ctrl.serverInfo = { name: "srv", version: "1.0.0" }; + const r = await mount(oneStdio()); + await expectFrame(r, "connected"); + }); +}); + +describe("App (status, layout, modals)", () => { + it("renders the connecting status symbol/color", async () => { + h.ctrl.status = "connecting"; + const r = await mount(oneStdio()); + await expectFrame(r, "connecting"); + }); + + it("renders the error status symbol/color", async () => { + h.ctrl.status = "error"; + const r = await mount(oneStdio()); + await expectFrame(r, "error"); + }); + + it("shows error status when an http server has a 401 response logged", async () => { + h.ctrl.status = "error"; + h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }]; + const r = await mount(oneHttp()); + await expectFrame(r, "error"); + await expectFrame(r, "HTTP Requests (1)"); + }); + + it("updates dimensions when the terminal resizes", async () => { + const r = await mount(oneStdio()); + process.stdout.emit("resize"); + await tick(); + await expectFrame(r, "MCP Servers"); + }); + + it("renders Tools tab content when connected", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneStdio()); + await press(r, ["t"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Tools (1)"); + expect(f).toContain("alpha"); + }); + + it("opens the tool test modal with Enter from the list pane", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneStdio()); + await press(r, ["t", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ESC]); // ESC closes the modal + expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM"); + }); + + it("opens the tool details modal with '+' and closes it on ESC", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneStdio()); + await press(r, ["t", TAB, TAB, "+"]); + await expectFrame(r, "Input Schema:"); + expect(r.lastFrame() ?? "").toContain("Full JSON:"); + await press(r, [ESC]); + expect(r.lastFrame() ?? "").not.toContain("Full JSON:"); + }); + + it("fetches a resource and opens its details modal", async () => { + h.ctrl.status = "connected"; + h.ctrl.resources = [sampleResource]; + const r = await mount(oneStdio()); + await press(r, ["r", TAB, ENTER]); + await tick(); + await press(r, [TAB, "+"]); + await expectFrame(r, "Full JSON:"); + }); + + it("opens the resource template test modal via Enter on a template", async () => { + h.ctrl.status = "connected"; + h.ctrl.resources = [sampleResource]; + h.ctrl.resourceTemplates = [sampleTemplate]; + const r = await mount(oneStdio()); + await press(r, ["r", TAB, DOWN, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ESC]); + expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM"); + }); + + it("opens the prompt test modal via Enter on a prompt with arguments", async () => { + h.ctrl.status = "connected"; + h.ctrl.prompts = [promptWithArgs]; + const r = await mount(oneStdio()); + await press(r, ["p", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ESC]); + expect(r.lastFrame() ?? "").not.toContain("MOCK_FORM"); + }); + + it("opens the prompt details modal with '+'", async () => { + h.ctrl.status = "connected"; + h.ctrl.prompts = [promptWithArgs]; + const r = await mount(oneStdio()); + await press(r, ["p", TAB, TAB, "+"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Arguments:"); + expect(f).toContain("Full JSON:"); + }); + + it("opens details for a nameless prompt with no arguments", async () => { + h.ctrl.status = "connected"; + h.ctrl.prompts = [promptNoName]; + const r = await mount(oneStdio()); + await press(r, ["p", TAB, TAB, "+"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Prompt: Unknown"); + expect(f).not.toContain("Arguments:"); + }); + + it("opens message details for a request message (with response)", async () => { + h.ctrl.messages = [reqMessage]; + const r = await mount(oneStdio()); + await press(r, ["m", TAB, TAB, "+"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Direction: request"); + expect(f).toContain("Response:"); + }); + + it("opens message details for a notification message", async () => { + h.ctrl.messages = [notifMessage]; + const r = await mount(oneStdio()); + await press(r, ["m", TAB, TAB, "+"]); + await expectFrame(r, "Notification:"); + }); + + it("opens message details for a response message", async () => { + h.ctrl.messages = [respMessage]; + const r = await mount(oneStdio()); + await press(r, ["m", TAB, TAB, "+"]); + await expectFrame(r, "Response:"); + }); + + it("opens in-progress request details (no status, error, or bodies)", async () => { + h.ctrl.status = "connected"; + h.ctrl.fetchRequests = [bareRequest]; + const r = await mount(oneHttp()); + await press(r, ["h", TAB, TAB, "+"]); + await expectFrame(r, "Request Headers:"); + }); + + it("connects with 'c' from the error state", async () => { + h.ctrl.status = "error"; + const r = await mount(oneStdio()); + await press(r, ["c"]); + await tick(); + expect(h.connect).toHaveBeenCalled(); + }); + + it("disconnects with 'd' from the connecting state", async () => { + h.ctrl.status = "connecting"; + const r = await mount(oneStdio()); + await press(r, ["d"]); + await tick(); + expect(h.disconnect).toHaveBeenCalled(); + }); + + it("renders the HTTP requests tab and opens full request details", async () => { + h.ctrl.status = "connected"; + h.ctrl.fetchRequests = [fullRequest]; + const r = await mount(oneHttp()); + await press(r, ["h", TAB, TAB, "+"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Request Headers:"); + expect(f).toContain("Status: 200"); + }); + + it("opens error-request details (error branch + unparseable bodies)", async () => { + h.ctrl.status = "connected"; + h.ctrl.fetchRequests = [errorRequest]; + const r = await mount(oneHttp()); + await press(r, ["h", TAB, TAB, "+"]); + await expectFrame(r, "Error: boom"); + }); + + it("renders the Logging tab for a stdio server", async () => { + h.ctrl.status = "connected"; + h.ctrl.stderrLogs = [stderrLog]; + const r = await mount(oneStdio()); + await press(r, ["l"]); + const f = r.lastFrame() ?? ""; + expect(f).toContain("Logging (1)"); + expect(f).toContain("log line"); + }); +}); + +describe("App (input handling, focus, effects)", () => { + it("switches tabs with left/right arrows when the tabs row is focused", async () => { + h.ctrl.status = "connected"; + const r = await mount(oneHttp()); + await press(r, [TAB]); // serverList -> tabs + await press(r, [LEFT, RIGHT, RIGHT, LEFT]); // wrap + cycle both directions + await expectFrame(r, "MCP Servers"); + }); + + it("switches tabs with arrows on a stdio server (logging tab, no requests)", async () => { + h.ctrl.status = "connected"; + const r = await mount(oneStdio()); + await press(r, [TAB]); // serverList -> tabs + await press(r, [RIGHT, RIGHT, LEFT]); + await expectFrame(r, "MCP Servers"); + }); + + it("updates the resources tab count when the resource list changes", async () => { + h.ctrl.status = "connected"; + h.ctrl.resources = []; + const r = await mount(oneStdio()); + await press(r, ["r"]); + h.ctrl.resources = [sampleResource]; + await press(r, [TAB]); // a focus change forces a re-render with new resources + await tick(); + await expectFrame(r, "Resources (1)"); + }); + + it("exits on Ctrl+C", async () => { + const r = await mount(oneStdio()); + r.stdin.write("\x03"); // ETX -> ctrl+c + await tick(); + expect(r.lastFrame() ?? "").toBeDefined(); + }); + + it("exits on Escape", async () => { + const r = await mount(oneStdio()); + await press(r, [ESC]); + expect(r.lastFrame() ?? "").toBeDefined(); + }); + + it("moves and wraps server selection with up and down arrows", async () => { + const r = await mount(stdioServer()); // alpha(0), beta(1); alpha selected + await press(r, [DOWN]); // alpha -> beta (down, index+1) + await press(r, [UP]); // beta -> alpha (up, index-1) + await press(r, [UP]); // alpha -> beta (up wrap to last) + await press(r, [DOWN]); // beta -> alpha (down wrap to first) + await expectFrame(r, "Server Configuration"); + }); + + it("handles arrow keys with an empty server catalog", async () => { + const r = await mount({}); + await press(r, [UP, DOWN]); + await expectFrame(r, "MCP Servers"); + }); + + it("cycles focus order through the messages tab panes", async () => { + h.ctrl.messages = [reqMessage]; + const r = await mount(oneStdio()); + await press(r, ["m"]); + await press(r, [TAB, TAB, TAB, TAB]); // forward through messages focusOrder + await press(r, [STAB, STAB]); // reverse + await expectFrame(r, "Messages"); + }); + + it("cycles focus order through the requests tab panes", async () => { + h.ctrl.status = "connected"; + h.ctrl.fetchRequests = [fullRequest]; + const r = await mount(oneHttp()); + await press(r, ["h"]); + await press(r, [TAB, TAB, TAB, TAB]); + await press(r, [STAB, STAB]); + await expectFrame(r, "Requests"); + }); + + it("switches away from the Auth tab when selecting a non-OAuth server", async () => { + const r = await mount(httpThenStdio()); + await press(r, ["a", STAB, STAB, DOWN]); + await waitUntil(() => (r.lastFrame() ?? "").includes("Type: stdio")); + await expectFrame(r, "Server Configuration"); + expect(r.lastFrame() ?? "").not.toContain("No OAuth information yet"); + }); + + it("switches away from the Logging tab when selecting a non-stdio server", async () => { + const r = await mount(stdioThenHttp()); + await press(r, ["l"]); // Logging tab (stdio) + await press(r, [STAB]); // tabs -> serverList + await press(r, [DOWN]); // select the http server -> effect leaves Logging + await expectFrame(r, "Server Configuration"); + }); + + it("swallows connect errors", async () => { + h.connect.mockRejectedValue(new Error("connfail")); + const r = await mount(oneStdio()); + await press(r, ["c"]); + await tick(); + expect(h.connect).toHaveBeenCalled(); + }); + + it("builds a client with saved settings (metadata, oauth, timeout)", async () => { + const r = await mount(httpWithSettings()); + await expectFrame(r, "MCP Servers"); + }); + + it("passes top-level oauth client credentials into an http client", async () => { + const r = render( + , + ); + mounted.push(r); + await tick(); + await expectFrame(r, "MCP Servers"); + }); +}); + +describe("App (OAuth flows)", () => { + const unauthorized = Object.assign(new Error("request failed (401)"), { + status: 401, + }); + + it("runs OAuth on connect when the server returns 401", async () => { + h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); + h.clientSpies.authenticate.mockResolvedValue(undefined); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.callbackStart.mock.calls.length > 0); + expect(h.disconnect).toHaveBeenCalled(); + expect(h.clientSpies.authenticate).toHaveBeenCalled(); + expect(h.connect).toHaveBeenCalledTimes(2); + }); + + it("completes OAuth when the callback fires during a 401 connect", async () => { + h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.cb.opts !== null); + await h.cb.opts!.onCallback({ code: "abc" }); + await tick(); + expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("abc"); + }); + + it("reports an OAuth callback error during a 401 connect", async () => { + h.connect.mockRejectedValueOnce(unauthorized); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.cb.opts !== null); + h.cb.opts!.onError({ error_description: "denied" }); + await expectFrame(r, "denied"); + }); + + it("clears OAuth state from the Auth tab", async () => { + const r = await mount(oneHttp()); + await press(r, ["a", "s"]); + await tick(); + expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled(); + }); + + it("reports callback completion failure during a 401 connect", async () => { + h.connect.mockRejectedValueOnce(unauthorized); + h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("qcfail")); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.cb.opts !== null); + await h.cb.opts!.onCallback({ code: "x" }); + await expectFrame(r, "qcfail"); + }); + + it("stringifies a non-Error authenticate rejection on 401 connect", async () => { + h.connect.mockRejectedValueOnce(unauthorized); + h.clientSpies.authenticate.mockRejectedValue("plainstring"); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await expectFrame(r, "plainstring"); + }); + + it("uses the default OAuth error label when the callback error is empty", async () => { + h.connect.mockRejectedValueOnce(unauthorized); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.cb.opts !== null); + h.cb.opts!.onError({}); + await expectFrame(r, "OAuth error"); + }); + + it("falls back to params.error when the callback has no description", async () => { + h.connect.mockRejectedValueOnce(unauthorized); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.cb.opts !== null); + h.cb.opts!.onError({ error: "oauth-error-code" }); + await expectFrame(r, "oauth-error-code"); + }); + + it("wraps a non-Error callback completion failure into an Error", async () => { + h.connect.mockRejectedValueOnce(unauthorized); + h.clientSpies.completeOAuthFlow.mockRejectedValue("cb-string"); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.cb.opts !== null); + await h.cb.opts!.onCallback({ code: "x" }); + await expectFrame(r, "cb-string"); + }); +}); diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx new file mode 100644 index 00000000..431391d2 --- /dev/null +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -0,0 +1,244 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "ink-testing-library"; +import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; + +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); + +import { AuthTab } from "../src/components/AuthTab.js"; + +const tick = async () => { + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +const sampleOAuthState: OAuthConnectionState = { + authorized: true, + protocol: "standard", + serverUrl: "http://x/mcp", + client: { + clientId: "abc123", + registrationKind: "dcr", + }, + tokens: { + access_token: "tok-abcdefghijklmnopqrstuvwxyz", + token_type: "Bearer", + }, + authorizationServerMetadata: { + authorization_endpoint: "https://auth.example.com/authorize", + }, + configuredScope: "read write", +}; + +function makeClient(oauthState?: OAuthConnectionState) { + const listeners = new Map void>>(); + const getOAuthState = vi.fn(async () => oauthState); + const client = { + getOAuthState, + addEventListener: (event: string, fn: () => void) => { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(fn); + }, + removeEventListener: (event: string, fn: () => void) => { + listeners.get(event)?.delete(fn); + }, + }; + const fire = (event: string) => { + listeners.get(event)?.forEach((fn) => fn()); + }; + return { + client: client as unknown as InspectorClient, + getOAuthState, + fire, + listeners, + }; +} + +const baseProps = { + serverName: "srv" as string | null, + serverConfig: null, + width: 120, + height: 30, + oauthRevision: 0, + onClearOAuth: vi.fn(), + connectionStatus: "disconnected" as const, +}; + +describe("AuthTab", () => { + it("renders the placeholder when there is no server", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain( + "Select a server to view authentication", + ); + }); + + it("renders OAuth details from getOAuthState", async () => { + const { client } = makeClient(sampleOAuthState); + const { lastFrame } = render( + , + ); + await tick(); + const frame = lastFrame() ?? ""; + expect(frame).toContain("OAuth Details"); + expect(frame).toContain("Authorized"); + expect(frame).toContain("abc123"); + expect(frame).toContain("Dynamic (DCR)"); + expect(frame).toContain("read, write"); + expect(frame).toContain("tok-abcdefghijklmnopqrst"); + }); + + it("shows the not-yet-authorized hint when getOAuthState is empty", async () => { + const { client } = makeClient(undefined); + const { lastFrame } = render( + , + ); + await tick(); + const frame = lastFrame() ?? ""; + expect(frame).toContain("No OAuth information yet"); + expect(frame).toContain("Connect (C) to authorize"); + }); + + it("renders authenticating and error status messages", async () => { + const { client } = makeClient(undefined); + const { lastFrame, rerender } = render( + , + ); + expect(lastFrame() ?? "").toContain("Authenticating"); + + rerender( + , + ); + expect(lastFrame() ?? "").toContain("Something went wrong"); + }); + + it("clears OAuth state on S and shows confirmation", async () => { + const onClearOAuth = vi.fn(); + const { client } = makeClient(sampleOAuthState); + const { lastFrame, stdin } = render( + , + ); + stdin.write("s"); + await tick(); + expect(onClearOAuth).toHaveBeenCalled(); + expect(lastFrame() ?? "").toContain("OAuth state cleared"); + }); + + it("shows clear+disconnect label when connected", async () => { + const { client } = makeClient(sampleOAuthState); + const { lastFrame } = render( + , + ); + await tick(); + expect(lastFrame() ?? "").toContain("clear+disconnect"); + }); + + it("shows the focused footer when focused", async () => { + const { client } = makeClient(sampleOAuthState); + const { lastFrame } = render( + , + ); + await tick(); + expect(lastFrame() ?? "").toContain("S clear"); + }); + + it("scrolls with arrow and page keys when focused", async () => { + const { client } = makeClient(sampleOAuthState); + const { lastFrame, stdin } = render( + , + ); + await tick(); + stdin.write(UP); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + expect(lastFrame() ?? "").toContain("OAuth Details"); + }); + + it("refreshes OAuth state when oauthComplete fires", async () => { + const { client, getOAuthState, fire, listeners } = + makeClient(sampleOAuthState); + const { unmount } = render( + , + ); + await tick(); + expect(getOAuthState).toHaveBeenCalled(); + expect(listeners.get("oauthComplete")?.size).toBe(1); + getOAuthState.mockClear(); + fire("oauthComplete"); + await tick(); + expect(getOAuthState).toHaveBeenCalled(); + unmount(); + expect(listeners.get("oauthComplete")?.size).toBe(0); + }); +}); diff --git a/clients/tui/__tests__/DetailsModal.test.tsx b/clients/tui/__tests__/DetailsModal.test.tsx new file mode 100644 index 00000000..41689568 --- /dev/null +++ b/clients/tui/__tests__/DetailsModal.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "ink-testing-library"; +import { Text } from "ink"; + +// ScrollView: passthrough so `content` mounts and the imperative ref API +// (scrollBy / getViewportHeight) exists for the scroll-key handlers. +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); + +import { DetailsModal } from "../src/components/DetailsModal.js"; + +// Ink processes stdin keypresses asynchronously — await this after stdin.write. +const tick = async () => { + // Flush several macrotask cycles so an effect -> setState -> re-render chain + // settles before assertions, even on slow/loaded CI (a single tick can race). + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +describe("DetailsModal", () => { + it("renders without crashing with content", () => { + const { unmount } = render( + some content} + width={120} + height={30} + onClose={() => {}} + />, + ); + // Modal is position="absolute" so lastFrame is empty; just confirm it + // mounted and unmounts cleanly (running the resize cleanup effect). + unmount(); + }); + + it("handles all scroll keys via the ScrollView ref", async () => { + const { stdin } = render( + scrollable} + width={120} + height={30} + onClose={() => {}} + />, + ); + + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(UP); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + }); + + it("ignores keys it does not handle", async () => { + const onClose = vi.fn(); + const { stdin } = render( + x} + width={120} + height={30} + onClose={onClose} + />, + ); + + await tick(); + // A plain character key matches none of the branches. + stdin.write("a"); + await tick(); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("calls onClose on ESC", async () => { + const onClose = vi.fn(); + const { stdin } = render( + x} + width={120} + height={30} + onClose={onClose} + />, + ); + + await tick(); + stdin.write(ESC); + await tick(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/tui/__tests__/HistoryTab.test.tsx b/clients/tui/__tests__/HistoryTab.test.tsx new file mode 100644 index 00000000..729d88c6 --- /dev/null +++ b/clients/tui/__tests__/HistoryTab.test.tsx @@ -0,0 +1,275 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "ink-testing-library"; +import type { MessageEntry } from "@inspector/core/mcp/index.js"; + +// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap +// in the non-TTY test env and never mounts its children. This passthrough +// renders children directly and stubs scrollBy/scrollTo/getViewportHeight. +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); + +import { HistoryTab } from "../src/components/HistoryTab.js"; + +// Ink processes stdin keypresses asynchronously — await this after stdin.write +// and after rerender() before asserting. +const tick = async () => { + // Flush several macrotask cycles so an effect -> setState -> re-render chain + // settles before assertions, even on slow/loaded CI (a single tick can race). + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +// Real terminal escape sequences so ink parses them as arrow / page keys. +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +const ts = new Date("2024-01-01T12:34:56Z"); + +const entry = (over: Partial): MessageEntry => + ({ + id: "id", + timestamp: ts, + direction: "request", + message: { jsonrpc: "2.0", id: 1, method: "ping" }, + ...over, + }) as unknown as MessageEntry; + +// One entry exercising each label / direction / detail branch. +const reqWithResponse = entry({ + id: "m0", + direction: "request", + message: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, + response: { jsonrpc: "2.0", id: 1, result: { tools: [] } }, + duration: 5, +}); +const reqPending = entry({ + id: "m1", + direction: "request", + message: { jsonrpc: "2.0", id: 2, method: "tools/call" }, +}); +const respResult = entry({ + id: "m2", + direction: "response", + message: { jsonrpc: "2.0", id: 3, result: { ok: true } }, +}); +const respError = entry({ + id: "m3", + direction: "response", + message: { jsonrpc: "2.0", id: 4, error: { code: -32601, message: "no" } }, +}); +const respPlain = entry({ + id: "m4", + direction: "response", + message: { jsonrpc: "2.0", id: 5 }, +}); +const notification = entry({ + id: "m5", + direction: "notification", + message: { jsonrpc: "2.0", method: "notifications/message" }, +}); +const unknownEntry = entry({ + id: "m6", + direction: "notification", + message: { jsonrpc: "2.0" }, +}); + +const allMessages: MessageEntry[] = [ + reqWithResponse, + reqPending, + respResult, + respError, + respPlain, + notification, + unknownEntry, +]; + +describe("HistoryTab", () => { + it("renders the empty state when there are no messages", () => { + const onCountChange = vi.fn(); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Messages (0)"); + expect(frame).toContain("No messages"); + expect(frame).toContain("Select a message to view details"); + expect(onCountChange).toHaveBeenCalledWith(0); + }); + + it("renders every list-label and direction-symbol variant", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Messages (7)"); + // request with response → "✓"; pending request → "..." + expect(frame).toContain("→ tools/list ✓"); + expect(frame).toContain("→ tools/call ..."); + // response labels + expect(frame).toContain("← Response (result)"); + expect(frame).toContain("← Response (error: -32601)"); + expect(frame).toContain("← Response"); + // notification + unknown + expect(frame).toContain("• notifications/message"); + expect(frame).toContain("• Unknown"); + expect(frame).toContain("▶ "); + }); + + it("renders request details with a response section and duration", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Direction: request"); + expect(frame).toContain("(5ms)"); + expect(frame).toContain("Request:"); + expect(frame).toContain("Response:"); + }); + + it("renders the waiting-for-response placeholder for a pending request", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Request:"); + expect(frame).toContain("Waiting for response..."); + }); + + it("renders response details with a Response label and Response header", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Direction: response"); + expect(frame).toContain("Response:"); + }); + + it("renders notification details with a Notification label", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Direction: notification"); + expect(frame).toContain("Notification:"); + // header uses the notification method + expect(frame).toContain("notifications/message"); + }); + + it("falls back to the Message header for a methodless notification", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("Message"); + }); + + it("moves selection with arrows and page keys when the list is focused", async () => { + const { lastFrame, stdin } = render( + , + ); + // up at top boundary: no movement + stdin.write(UP); + await tick(); + // down to the next message + stdin.write(DOWN); + await tick(); + expect(lastFrame() ?? "").toContain("Direction: request"); + // pageDown jumps toward the end, pageUp back toward the start + stdin.write(PAGE_DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + // up to move back toward the top + stdin.write(UP); + await tick(); + expect(lastFrame() ?? "").toContain("Messages (7)"); + }); + + it("handles details-pane scrolling, footer, and zoom shortcut", async () => { + const onViewDetails = vi.fn(); + const { lastFrame, stdin } = render( + , + ); + expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom"); + stdin.write(UP); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + stdin.write("+"); + await tick(); + expect(onViewDetails).toHaveBeenCalledWith(allMessages[0]); + }); + + it("does not fire input handlers when a modal is open", async () => { + const onViewDetails = vi.fn(); + const { stdin } = render( + , + ); + stdin.write("+"); + await tick(); + expect(onViewDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/tui/__tests__/InfoTab.test.tsx b/clients/tui/__tests__/InfoTab.test.tsx new file mode 100644 index 00000000..543b6fde --- /dev/null +++ b/clients/tui/__tests__/InfoTab.test.tsx @@ -0,0 +1,401 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "ink-testing-library"; + +// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap +// in the non-TTY test env and never mounts its children. This passthrough +// renders children directly and stubs scrollBy/scrollTo/getViewportHeight. +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); + +import type { + MCPServerConfig, + ServerState, +} from "@inspector/core/mcp/index.js"; +import { InfoTab } from "../src/components/InfoTab.js"; + +// Ink processes stdin keypresses asynchronously — await this after stdin.write +// and after rerender() before asserting. +const tick = async () => { + // Flush several macrotask cycles so an effect -> setState -> re-render chain + // settles before assertions, even on slow/loaded CI (a single tick can race). + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +// Real terminal escape sequences (with the leading ESC) so ink reliably parses +// them as arrow / page keys. +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +const stdioConfig: MCPServerConfig = { + type: "stdio", + command: "node", + args: ["server.js", "--flag"], + env: { FOO: "bar", BAZ: "qux" }, + cwd: "/tmp/work", +}; + +const baseState: ServerState = { + status: "disconnected", + error: null, + resources: [], + prompts: [], + tools: [], + stderrLogs: [], +}; + +describe("InfoTab", () => { + it("renders nothing beyond the header when serverName is null", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Info"); + expect(frame).not.toContain("Server Configuration"); + }); + + it("renders 'No configuration available' when config is null", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Server Configuration"); + expect(frame).toContain("No configuration available"); + expect(frame).toContain("Server not connected"); + }); + + it("renders a full stdio config (command, args, env, cwd)", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Type: stdio"); + expect(frame).toContain("Command: node"); + expect(frame).toContain("Args:"); + expect(frame).toContain("server.js"); + expect(frame).toContain("--flag"); + expect(frame).toContain("Env:"); + expect(frame).toContain("FOO=bar"); + expect(frame).toContain("CWD: /tmp/work"); + }); + + it("renders a stdio config with type omitted (undefined defaults to stdio)", () => { + const config: MCPServerConfig = { + command: "python", + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Type: stdio"); + expect(frame).toContain("Command: python"); + // No args/env/cwd → those optional blocks are skipped + expect(frame).not.toContain("Args:"); + expect(frame).not.toContain("Env:"); + expect(frame).not.toContain("CWD:"); + }); + + it("renders an sse config with headers", () => { + const config: MCPServerConfig = { + type: "sse", + url: "https://example.com/sse", + }; + const withHeaders = { + ...config, + headers: { Authorization: "Bearer x" }, + } as unknown as MCPServerConfig; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Type: sse"); + expect(frame).toContain("URL: https://example.com/sse"); + expect(frame).toContain("Headers:"); + expect(frame).toContain("Authorization=Bearer x"); + }); + + it("renders an sse config without headers", () => { + const config: MCPServerConfig = { + type: "sse", + url: "https://example.com/sse", + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Type: sse"); + expect(frame).not.toContain("Headers:"); + }); + + it("renders a streamable-http config with headers", () => { + const config = { + type: "streamable-http", + url: "https://example.com/mcp", + headers: { "X-Key": "abc" }, + } as unknown as MCPServerConfig; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Type: streamable-http"); + expect(frame).toContain("URL: https://example.com/mcp"); + expect(frame).toContain("Headers:"); + expect(frame).toContain("X-Key=abc"); + }); + + it("renders a streamable-http config without headers", () => { + const config: MCPServerConfig = { + type: "streamable-http", + url: "https://example.com/mcp", + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Type: streamable-http"); + expect(frame).not.toContain("Headers:"); + }); + + it("renders connected server information (name, version, instructions)", () => { + const state: ServerState = { + ...baseState, + status: "connected", + serverInfo: { name: "Test Server", version: "1.2.3" }, + instructions: "Use me wisely", + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Server Information"); + expect(frame).toContain("Name: Test Server"); + expect(frame).toContain("Version: 1.2.3"); + expect(frame).toContain("Instructions:"); + expect(frame).toContain("Use me wisely"); + }); + + it("renders connected server info without optional fields", () => { + const state: ServerState = { + ...baseState, + status: "connected", + serverInfo: { name: "", version: "" }, + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Server Information"); + expect(frame).not.toContain("Name:"); + expect(frame).not.toContain("Version:"); + expect(frame).not.toContain("Instructions:"); + }); + + it("does not render Server Information when connected but no serverInfo", () => { + const state: ServerState = { + ...baseState, + status: "connected", + }; + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").not.toContain("Server Information"); + }); + + it("renders an error status with error message", () => { + const state: ServerState = { + ...baseState, + status: "error", + error: "boom failed to connect", + }; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Error"); + expect(frame).toContain("boom failed to connect"); + }); + + it("renders an error status without an error message", () => { + const state: ServerState = { + ...baseState, + status: "error", + error: null, + }; + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("Error"); + }); + + it("shows the footer and handles scroll keys when focused", async () => { + const { lastFrame, stdin } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("to scroll"); + + // Drive every useInput branch + stdin.write(""); // up arrow + await tick(); + stdin.write(""); // down arrow + await tick(); + stdin.write("[5~"); // pageUp + await tick(); + stdin.write("[6~"); // pageDown + await tick(); + // A non-handled key (no branch) to exercise the else fall-through + stdin.write("x"); + await tick(); + + expect(lastFrame() ?? "").toContain("Info"); + }); + + it("renders no config details for an unrecognized server type", () => { + // type is none of stdio / sse / streamable-http → the final `: null` + // ternary branch in the config block. + const config = { + type: "websocket", + url: "ws://example.com", + } as unknown as MCPServerConfig; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Server Configuration"); + expect(frame).not.toContain("Type: stdio"); + expect(frame).not.toContain("Type: sse"); + expect(frame).not.toContain("Type: streamable-http"); + }); + + it("handles scroll keys when focused but the scroll ref is null", async () => { + // serverName is null → no ScrollView mounts, so scrollViewRef.current is + // null. The handler is still active (isActive: focused), so the keys + // exercise the optional-chaining + `getViewportHeight() || 1` fallbacks. + const { stdin } = render( + , + ); + stdin.write(UP); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(PAGE_UP); // null ref → `|| 1` + await tick(); + stdin.write(PAGE_DOWN); // null ref → `|| 1` + await tick(); + expect(true).toBe(true); + }); + + it("does not show the footer when not focused", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").not.toContain("to scroll"); + }); +}); diff --git a/clients/tui/__tests__/NotificationsTab.test.tsx b/clients/tui/__tests__/NotificationsTab.test.tsx new file mode 100644 index 00000000..ed51b39b --- /dev/null +++ b/clients/tui/__tests__/NotificationsTab.test.tsx @@ -0,0 +1,184 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "ink-testing-library"; + +// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap +// in the non-TTY test env and never mounts its children. This passthrough +// renders children directly and stubs scrollBy/scrollTo/getViewportHeight. +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); + +import type { StderrLogEntry } from "@inspector/core/mcp/index.js"; +import { NotificationsTab } from "../src/components/NotificationsTab.js"; + +// Ink processes stdin keypresses asynchronously — await this after stdin.write +// and after rerender() before asserting. +const tick = async () => { + // Flush several macrotask cycles so an effect -> setState -> re-render chain + // settles before assertions, even on slow/loaded CI (a single tick can race). + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +// Real terminal escape sequences (with the leading ESC) so ink reliably parses +// them as arrow / page keys for this component's useInput handler. +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +const makeLog = (message: string): StderrLogEntry => ({ + timestamp: new Date("2026-06-27T12:34:56Z"), + message, +}); + +describe("NotificationsTab", () => { + it("renders the empty state when there are no logs", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Logging (0)"); + expect(frame).toContain("No stderr output yet"); + }); + + it("renders log entries with timestamps and messages", () => { + const logs = [makeLog("first error"), makeLog("second error")]; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Logging (2)"); + expect(frame).toContain("first error"); + expect(frame).toContain("second error"); + expect(frame).not.toContain("No stderr output yet"); + }); + + it("invokes onCountChange with the initial log count", () => { + const onCountChange = vi.fn(); + render( + , + ); + expect(onCountChange).toHaveBeenCalledWith(2); + }); + + it("re-invokes onCountChange when the log count changes", async () => { + const onCountChange = vi.fn(); + const { rerender } = render( + , + ); + expect(onCountChange).toHaveBeenLastCalledWith(1); + + rerender( + , + ); + await tick(); + expect(onCountChange).toHaveBeenLastCalledWith(3); + }); + + it("picks up a changed onCountChange callback via the ref effect", async () => { + const first = vi.fn(); + const second = vi.fn(); + const { rerender } = render( + , + ); + expect(first).toHaveBeenLastCalledWith(1); + + // New callback identity + new count → the ref is updated, then the + // count-change effect fires the latest callback. + rerender( + , + ); + await tick(); + expect(second).toHaveBeenLastCalledWith(2); + }); + + it("renders without an onCountChange prop", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("solo"); + }); + + it("highlights the header and handles scroll keys when focused", async () => { + const logs = [makeLog("line one"), makeLog("line two")]; + const { lastFrame, stdin } = render( + , + ); + expect(lastFrame() ?? "").toContain("Logging (2)"); + + // Drive every useInput branch + stdin.write(UP); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + stdin.write("x"); // non-handled key → else fall-through + await tick(); + + expect(lastFrame() ?? "").toContain("line one"); + }); + + it("handles scroll keys with no logs (ScrollView absent → null scroll ref)", async () => { + // With an empty log list the ScrollView (and its ref) is never mounted, so + // scrollViewRef.current is null. Driving the scroll keys exercises the + // optional-chaining + `getViewportHeight() || 1` fallback paths. + const { lastFrame, stdin } = render( + , + ); + expect(lastFrame() ?? "").toContain("No stderr output yet"); + + stdin.write(UP); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + + expect(lastFrame() ?? "").toContain("No stderr output yet"); + }); + + it("does not react to keys when not focused", async () => { + const logs = [makeLog("line one")]; + const { lastFrame, stdin } = render( + , + ); + stdin.write(DOWN); + await tick(); + expect(lastFrame() ?? "").toContain("line one"); + }); +}); diff --git a/clients/tui/__tests__/PromptTestModal.test.tsx b/clients/tui/__tests__/PromptTestModal.test.tsx new file mode 100644 index 00000000..1a1d0582 --- /dev/null +++ b/clients/tui/__tests__/PromptTestModal.test.tsx @@ -0,0 +1,344 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render } from "ink-testing-library"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; + +// ScrollView: passthrough so the results JSX actually mounts (and is counted +// for coverage) and the imperative ref API exists for the scroll handlers. +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); +// Form: a double that fires onSubmit when the user presses Enter ("\r"). +vi.mock("ink-form", () => import("./helpers/inkFormMock.js")); + +import { PromptTestModal } from "../src/components/PromptTestModal.js"; + +// Ink processes stdin keypresses asynchronously — await this after stdin.write +// and let the async getPrompt promise + setState settle. +const tick = async () => { + // Flush several macrotask cycles so an effect -> setState -> re-render chain + // settles before assertions, even on slow/loaded CI (a single tick can race). + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; +const ENTER = "\r"; + +const makePrompt = (over: Partial = {}): Prompt => + ({ + name: "alpha", + description: "First prompt", + arguments: [{ name: "topic", description: "a topic", required: true }], + ...over, + }) as unknown as Prompt; + +// Set the value the mock Form submits on Enter; reset afterward so cases +// don't leak into one another. +function setFormSubmitValue(value: Record | undefined) { + if (value === undefined) { + delete (globalThis as Record).__INK_FORM_SUBMIT_VALUE__; + } else { + (globalThis as Record).__INK_FORM_SUBMIT_VALUE__ = value; + } +} + +afterEach(() => { + setFormSubmitValue(undefined); + vi.restoreAllMocks(); +}); + +describe("PromptTestModal", () => { + it("submits the form, calls getPrompt, and shows results (success path)", async () => { + const getPrompt = vi.fn().mockResolvedValue({ + result: { + description: "ok", + messages: [{ role: "user", content: { type: "text", text: "hello" } }], + }, + }); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + const prompt = makePrompt(); + + // Submit a non-empty value so the "Arguments:" block (input length > 0) + // is rendered in the results view. + setFormSubmitValue({ topic: "weather" }); + + const { stdin } = render( + {}} + />, + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(getPrompt).toHaveBeenCalledWith("alpha", { topic: "weather" }); + + // Now in results mode — drive every scroll key branch. + stdin.write(DOWN); + await tick(); + stdin.write(UP); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + }); + + it("renders the loading state while getPrompt is pending", async () => { + let resolveGetPrompt!: (value: { result: { messages: unknown[] } }) => void; + const getPrompt = vi.fn().mockReturnValue( + new Promise<{ result: { messages: unknown[] } }>((resolve) => { + resolveGetPrompt = resolve; + }), + ); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + const { stdin } = render( + {}} + />, + ); + + await tick(); + stdin.write(ENTER); + // One tick: state has transitioned to "loading" but the promise is still + // pending, so the loading JSX is mounted. + await tick(); + expect(getPrompt).toHaveBeenCalledTimes(1); + + // Now let it resolve and settle into results. + resolveGetPrompt({ result: { messages: [] } }); + await tick(); + await tick(); + }); + + it("renders results with no arguments block when submitted value is empty", async () => { + const getPrompt = vi.fn().mockResolvedValue({ + result: { messages: [] }, + }); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + // Default submit value is {} → Object.keys(input).length === 0 branch. + const { stdin } = render( + {}} + />, + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(getPrompt).toHaveBeenCalledTimes(1); + }); + + it("shows error results when getPrompt rejects with an Error", async () => { + const getPrompt = vi.fn().mockRejectedValue(new Error("boom")); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + setFormSubmitValue({ topic: "x" }); + + const { stdin } = render( + {}} + />, + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(getPrompt).toHaveBeenCalledTimes(1); + + // Scroll through the error results to exercise the results scroll branches. + stdin.write(DOWN); + await tick(); + }); + + it("handles a rejected getPrompt that throws a string", async () => { + const getPrompt = vi.fn().mockRejectedValue("string failure"); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + const { stdin } = render( + {}} + />, + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(getPrompt).toHaveBeenCalledTimes(1); + }); + + it("handles a rejected getPrompt that throws an object with a message", async () => { + const getPrompt = vi + .fn() + .mockRejectedValue({ message: "obj msg", code: 7 }); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + const { stdin } = render( + {}} + />, + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(getPrompt).toHaveBeenCalledTimes(1); + }); + + it("handles a rejected getPrompt that throws an object without a message", async () => { + const getPrompt = vi.fn().mockRejectedValue({ code: 99 }); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + const { stdin } = render( + {}} + />, + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(getPrompt).toHaveBeenCalledTimes(1); + }); + + it("handles a rejected getPrompt that throws a non-object, non-string value", async () => { + const getPrompt = vi.fn().mockRejectedValue(42); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + const { stdin } = render( + {}} + />, + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(getPrompt).toHaveBeenCalledTimes(1); + }); + + it("does nothing when inspectorClient is null (early return)", async () => { + const onClose = vi.fn(); + const { stdin } = render( + , + ); + + await tick(); + // Submitting the form hits handleFormSubmit which returns early because + // inspectorClient is null — state stays "form", no crash. + stdin.write(ENTER); + await tick(); + await tick(); + + // Still in form mode: a non-escape key is ignored by the form-state branch. + stdin.write("a"); + await tick(); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("closes on ESC and resets state", async () => { + const onClose = vi.fn(); + const getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } }); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + const { stdin } = render( + , + ); + + await tick(); + // Get into results mode first so the escape-from-results path runs. + stdin.write(ENTER); + await tick(); + await tick(); + + stdin.write(ESC); + await tick(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("unmounts cleanly, running cleanup effects", async () => { + const getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } }); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + + const { unmount } = render( + {}} + />, + ); + + await tick(); + unmount(); + await tick(); + expect(true).toBe(true); + }); +}); diff --git a/clients/tui/__tests__/PromptsTab.test.tsx b/clients/tui/__tests__/PromptsTab.test.tsx new file mode 100644 index 00000000..c36a8fc0 --- /dev/null +++ b/clients/tui/__tests__/PromptsTab.test.tsx @@ -0,0 +1,291 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "ink-testing-library"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; + +// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap +// in the non-TTY test env and never mounts its children. This passthrough +// renders children directly and stubs scrollBy/scrollTo/getViewportHeight. +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); + +import { PromptsTab } from "../src/components/PromptsTab.js"; + +// Ink processes stdin keypresses asynchronously — await this after stdin.write +// and after rerender() before asserting. The longer delay also lets the async +// getPrompt IIFE + setState settle. +const tick = async () => { + // Flush several macrotask cycles so an effect -> setState -> re-render chain + // settles before assertions, even on slow/loaded CI (a single tick can race). + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +const makePrompt = (over: Partial = {}): Prompt => + ({ + name: "alpha", + description: "First prompt", + ...over, + }) as unknown as Prompt; + +// p0: full prompt with multi-line description + three argument variants +// (description present / `type` fallback / "string" fallback) +// p1: prompt with no description and no arguments (absent branches) +// p2: empty name → "Prompt N" fallback label + index key +// p3: trailing prompt for scroll-window coverage +const prompts: Prompt[] = [ + makePrompt({ + name: "alpha", + description: "Line one\nLine two", + arguments: [ + { name: "withDesc", description: "the description" }, + { name: "withType", type: "number" } as never, + { name: "bare" }, + ], + }), + makePrompt({ name: "beta", description: undefined, arguments: undefined }), + makePrompt({ name: "", description: "gamma desc" }), + makePrompt({ name: "delta", description: "Delta desc" }), +]; + +describe("PromptsTab", () => { + it("renders empty state when there are no prompts", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Prompts (0)"); + expect(frame).toContain("No prompts available"); + expect(frame).toContain("Select a prompt to view details"); + }); + + it("renders a populated list with the first prompt selected (unfocused)", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Prompts (4)"); + expect(frame).toContain("alpha"); + expect(frame).toContain("beta"); + // empty-name prompt falls back to "Prompt N" + expect(frame).toContain("Prompt 3"); + expect(frame).toContain("▶ "); + // selected prompt details (cyan branch since details not focused) + expect(frame).toContain("Line one"); + expect(frame).toContain("Line two"); + // arguments + their three description fallbacks + expect(frame).toContain("Arguments:"); + expect(frame).toContain("the description"); + expect(frame).toContain("number"); + expect(frame).toContain("bare: string"); + expect(frame).toContain("[Enter to Get Prompt]"); + }); + + it("moves selection down/up with arrow keys when the list is focused", async () => { + const { lastFrame, stdin } = render( + , + ); + // up at the top boundary: no movement + stdin.write(UP); + await tick(); + // down moves selection to "beta" (no description, no arguments) + stdin.write(DOWN); + await tick(); + let frame = lastFrame() ?? ""; + expect(frame).toContain("beta"); + expect(frame).not.toContain("Arguments:"); + // back up to alpha + stdin.write(UP); + await tick(); + frame = lastFrame() ?? ""; + expect(frame).toContain("Line one"); + }); + + it("scrolls the visible window when navigating past the viewport", async () => { + // height 9 → visibleCount = 2; 4 prompts force firstVisible to advance + const { lastFrame, stdin } = render( + , + ); + // Overshoot: the downArrow guard clamps at the last index, so extra + // presses are harmless and absorb any dropped first keypress. + stdin.write(DOWN); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(DOWN); + await tick(); + const frame = lastFrame() ?? ""; + expect(frame).toContain("delta"); + // down boundary: pressing down again does nothing + stdin.write(DOWN); + await tick(); + expect(lastFrame() ?? "").toContain("delta"); + }); + + it("calls onFetchPrompt when Enter is pressed on a prompt with arguments", async () => { + const onFetchPrompt = vi.fn(); + const inspectorClient = { + getPrompt: vi.fn(), + } as unknown as InspectorClient; + const { stdin } = render( + , + ); + stdin.write("\r"); + await tick(); + expect(onFetchPrompt).toHaveBeenCalledWith(prompts[0]); + }); + + it("fetches directly and calls onViewDetails when Enter is pressed on an argument-less prompt", async () => { + const onFetchPrompt = vi.fn(); + const onViewDetails = vi.fn(); + const result = { messages: [] }; + const getPrompt = vi.fn().mockResolvedValue({ result }); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + const noArgPrompt = makePrompt({ name: "solo", arguments: undefined }); + const { stdin } = render( + , + ); + stdin.write("\r"); + await tick(); + expect(getPrompt).toHaveBeenCalledWith("solo"); + expect(onFetchPrompt).not.toHaveBeenCalled(); + expect(onViewDetails).toHaveBeenCalledWith( + expect.objectContaining({ name: "solo", result }), + ); + }); + + it("renders the Error message when getPrompt rejects with an Error", async () => { + const getPrompt = vi.fn().mockRejectedValue(new Error("boom failure")); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + const noArgPrompt = makePrompt({ name: "solo", arguments: undefined }); + const { lastFrame, stdin } = render( + , + ); + stdin.write("\r"); + await tick(); + expect(lastFrame() ?? "").toContain("boom failure"); + }); + + it("falls back to a generic Error message when getPrompt rejects with a non-Error", async () => { + const getPrompt = vi.fn().mockRejectedValue("oops"); + const inspectorClient = { getPrompt } as unknown as InspectorClient; + const noArgPrompt = makePrompt({ name: "solo", arguments: undefined }); + const { lastFrame, stdin } = render( + , + ); + stdin.write("\r"); + await tick(); + expect(lastFrame() ?? "").toContain("Failed to get prompt"); + }); + + it("handles details-pane scrolling, footer, and zoom shortcut", async () => { + const onViewDetails = vi.fn(); + const { lastFrame, stdin } = render( + , + ); + // footer only shows while the details pane is focused + expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom"); + // scroll keys (exercise scrollBy / pageUp / pageDown branches) + stdin.write(UP); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + // "+" opens the full-screen modal + stdin.write("+"); + await tick(); + expect(onViewDetails).toHaveBeenCalledWith(prompts[0]); + }); + + it("does not fire input handlers when a modal is open", async () => { + const onFetchPrompt = vi.fn(); + const inspectorClient = { + getPrompt: vi.fn(), + } as unknown as InspectorClient; + const { stdin } = render( + , + ); + stdin.write("\r"); + await tick(); + expect(onFetchPrompt).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/tui/__tests__/RequestsTab.test.tsx b/clients/tui/__tests__/RequestsTab.test.tsx new file mode 100644 index 00000000..8c504eb1 --- /dev/null +++ b/clients/tui/__tests__/RequestsTab.test.tsx @@ -0,0 +1,364 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "ink-testing-library"; +import type { FetchRequestEntry } from "@inspector/core/mcp/index.js"; + +// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap +// in the non-TTY test env and never mounts its children. This passthrough +// renders children directly and stubs scrollBy/scrollTo/getViewportHeight. +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); + +import { RequestsTab } from "../src/components/RequestsTab.js"; + +// Ink processes stdin keypresses asynchronously — await this after stdin.write. +const tick = async () => { + // Flush several macrotask cycles so an effect -> setState -> re-render chain + // settles before assertions, even on slow/loaded CI (a single tick can race). + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; + +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +const TS = new Date("2024-01-01T12:34:56Z"); + +const req = (over: Partial): FetchRequestEntry => ({ + id: "id", + timestamp: TS, + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { "content-type": "application/json" }, + category: "transport", + ...over, +}); + +// A fully-populated, successful transport request (2xx, JSON bodies). +const fullRequest = req({ + id: "r0", + category: "auth", + method: "GET", + url: "https://example.com/oauth", + responseStatus: 200, + responseStatusText: "OK", + duration: 12, + requestHeaders: { authorization: "Bearer x" }, + requestBody: JSON.stringify({ grant_type: "code" }), + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ access_token: "tok" }), +}); + +const errorRequest = req({ + id: "r1", + method: "POST", + error: "connection refused", +}); + +const pendingRequest = req({ + id: "r2", + method: "DELETE", +}); + +const nonJsonRequest = req({ + id: "r3", + method: "PUT", + responseStatus: 404, + responseStatusText: "Not Found", + requestBody: "this is not json", + responseHeaders: {}, + responseBody: "neither is this", +}); + +const redirectRequest = req({ + id: "r4", + method: "GET", + responseStatus: 301, +}); + +const informationalRequest = req({ + id: "r5", + method: "GET", + responseStatus: 100, +}); + +describe("RequestsTab", () => { + it("renders the empty state and reports a count of 0", () => { + const onCountChange = vi.fn(); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Requests (0)"); + expect(frame).toContain("No requests"); + expect(frame).toContain("Select a request to view details"); + expect(onCountChange).toHaveBeenCalledWith(0); + }); + + it("works without an onCountChange callback", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("Requests (0)"); + }); + + it("renders the list with status colors, labels and durations", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Requests (4)"); + // category labels + expect(frame).toContain("AUTH"); + expect(frame).toContain("MCP"); + // GET is padded; non-GET methods shown as-is + expect(frame).toContain("GET"); + expect(frame).toContain("POST"); + // status text variants: numeric / ERROR / "..." + expect(frame).toContain("200"); + expect(frame).toContain("ERROR"); + expect(frame).toContain("..."); + // duration suffix + expect(frame).toContain("12ms"); + // selection marker + expect(frame).toContain("▶ "); + }); + + it("renders full details for a successful request with JSON bodies", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("GET https://example.com/oauth"); + expect(frame).toContain("Category:"); + expect(frame).toContain("auth"); + expect(frame).toContain("Status:"); + expect(frame).toContain("200 OK"); + expect(frame).toContain("(12ms)"); + expect(frame).toContain("Request Headers:"); + expect(frame).toContain("authorization: Bearer x"); + expect(frame).toContain("Request Body:"); + expect(frame).toContain("grant_type"); + expect(frame).toContain("Response Headers:"); + expect(frame).toContain("Response Body:"); + expect(frame).toContain("access_token"); + }); + + it("renders an error request detail", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("transport"); + expect(frame).toContain("Error: connection refused"); + }); + + it("renders the in-progress placeholder when there is no status or error", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("Request in progress..."); + }); + + it("renders raw (non-JSON) request and response bodies", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("404 Not Found"); + expect(frame).toContain("this is not json"); + expect(frame).toContain("neither is this"); + // empty responseHeaders object → section omitted + expect(frame).not.toContain("Response Headers:"); + }); + + it("renders a redirect (3xx) status detail", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("301"); + }); + + it("highlights the details header when the details pane is focused", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("↑/↓ to scroll, + to zoom"); + expect(frame).toContain("GET https://example.com/oauth"); + }); + + it("moves selection with arrows and page keys when the list is focused", async () => { + const many: FetchRequestEntry[] = Array.from({ length: 8 }, (_, i) => + req({ + id: `m${i}`, + method: i % 2 === 0 ? "GET" : "POST", + url: `https://example.com/r${i}`, + responseStatus: 200, + }), + ); + const { lastFrame, stdin } = render( + , + ); + // up at top boundary: no movement + stdin.write(UP); + await tick(); + // down moves selection to the next request + stdin.write(DOWN); + await tick(); + expect(lastFrame() ?? "").toContain("https://example.com/r1"); + // pageDown jumps toward the end + stdin.write(PAGE_DOWN); + await tick(); + expect(lastFrame() ?? "").toContain("https://example.com/r"); + // pageUp back toward the start + stdin.write(PAGE_UP); + await tick(); + // up moves back toward the top + stdin.write(UP); + await tick(); + expect(lastFrame() ?? "").toContain("Requests (8)"); + }); + + it("clamps at the bottom when paging past the end", async () => { + const many: FetchRequestEntry[] = Array.from({ length: 4 }, (_, i) => + req({ id: `c${i}`, url: `https://example.com/c${i}` }), + ); + const { lastFrame, stdin } = render( + , + ); + // overshoot down to the last index, then page down past the end + stdin.write(DOWN); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + stdin.write(DOWN); + await tick(); + expect(lastFrame() ?? "").toContain("https://example.com/c3"); + }); + + it("handles details-pane scrolling and the zoom shortcut", async () => { + const onViewDetails = vi.fn(); + const { lastFrame, stdin } = render( + , + ); + expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom"); + stdin.write(UP); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(PAGE_UP); + await tick(); + stdin.write(PAGE_DOWN); + await tick(); + stdin.write("+"); + await tick(); + expect(onViewDetails).toHaveBeenCalledWith(fullRequest); + }); + + it("does not fire input handlers when a modal is open", async () => { + const onViewDetails = vi.fn(); + const { stdin } = render( + , + ); + stdin.write("+"); + await tick(); + expect(onViewDetails).not.toHaveBeenCalled(); + }); + + it("ignores '+' when no onViewDetails handler is provided", async () => { + const { lastFrame, stdin } = render( + , + ); + stdin.write("+"); + await tick(); + // still rendered, no crash + expect(lastFrame() ?? "").toContain("GET https://example.com/oauth"); + }); +}); diff --git a/clients/tui/__tests__/ResourceTestModal.test.tsx b/clients/tui/__tests__/ResourceTestModal.test.tsx new file mode 100644 index 00000000..37103ba7 --- /dev/null +++ b/clients/tui/__tests__/ResourceTestModal.test.tsx @@ -0,0 +1,267 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render } from "ink-testing-library"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; + +// ScrollView passthrough so the results JSX actually mounts (and is covered). +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); +// Form double that fires onSubmit when the user presses Enter ("\r"). +vi.mock("ink-form", () => import("./helpers/inkFormMock.js")); + +import { ResourceTestModal } from "../src/components/ResourceTestModal.js"; + +// These modals render position="absolute", which produces an EMPTY frame under +// ink-testing-library. So we assert on BEHAVIOR — the injected client fake's +// readResourceFromTemplate, onClose, and the state transitions they drive — +// rather than on lastFrame(). React still EXECUTES the inner results/error JSX, +// so its coverage is collected even though it isn't visible. + +const tick = async () => { + // Flush several macrotask cycles so an effect -> setState -> re-render chain + // settles before assertions, even on slow/loaded CI (a single tick can race). + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +type Template = { + name: string; + uriTemplate: string; + description?: string; +}; + +const makeTemplate = (over: Partial