Merge remote-tracking branch 'origin/v2/main' into merge-1537
This commit is contained in:
commit
3df4f0e7e2
154 changed files with 13345 additions and 3915 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@ test-servers/build
|
||||||
/*.png
|
/*.png
|
||||||
/inspector-history-*.json
|
/inspector-history-*.json
|
||||||
/*.server.json
|
/*.server.json
|
||||||
|
/configs/
|
||||||
|
|
|
||||||
70
AGENTS.md
70
AGENTS.md
|
|
@ -67,11 +67,10 @@ After installing, `npm run build` builds all clients. The launcher scripts (`npm
|
||||||
## Repository & Project Board
|
## Repository & Project Board
|
||||||
|
|
||||||
- **Repo**: https://github.com/modelcontextprotocol/inspector.git
|
- **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**:
|
- **Project Boards**:
|
||||||
- v2 - https://github.com/orgs/modelcontextprotocol/projects/28
|
- v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all current work goes here)
|
||||||
- v1.5 - https://github.com/orgs/modelcontextprotocol/projects/39
|
- v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (existing inspector version, no new activity except security and bug fixes)
|
||||||
- v1 - https://github.com/orgs/modelcontextprotocol/projects/11
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
@ -83,11 +82,11 @@ diff. See [`CONTRIBUTORS.md`](./CONTRIBUTORS.md) for the full policy.
|
||||||
## Project Status and Direction
|
## 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 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
|
## 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.
|
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.
|
- 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 <login>`). Board items should never be unassigned.
|
||||||
- **Label by version.** New issues and PRs must carry the label matching the target board / branch:
|
- **Label by version.** New issues and PRs must carry the label matching the target board / branch:
|
||||||
- `main` → `v1`
|
- `main` → `v1`
|
||||||
- `v1.5/main` → `v1.5`
|
|
||||||
- `v2/main` → `v2`
|
- `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.
|
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:
|
- When work is complete:
|
||||||
- Run format, lint, typecheck, build, and test — ensure all checks pass
|
- 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 #<ISSUE_NUMBER>`**. ⚠️ 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.
|
- 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 <issue-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 <item-id-from-step-1> \
|
||||||
|
--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 <issue-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
|
### Always test new or modified code
|
||||||
- Ensure all code has corresponding tests
|
- Ensure all code has corresponding tests
|
||||||
- Ensure test coverage for each file is at least 90%
|
- 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:
|
- 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.
|
- **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.
|
- **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.
|
- 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 `<Name>.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`.
|
- Test files live alongside the source as `<Name>.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
|
- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme
|
||||||
|
|
||||||
### Responding to Code Reviews
|
### Responding to Code Reviews
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,49 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
|
||||||
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
|
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
|
||||||
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
|
| `--tool-metadata <key=value>` | 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 <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
|
||||||
|
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
|
||||||
|
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
|
||||||
|
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
|
||||||
|
| `--callback-url <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?
|
## Why use the CLI?
|
||||||
|
|
||||||
While the Web Client provides a rich visual interface, the CLI is designed for:
|
While the Web Client provides a rich visual interface, the CLI is designed for:
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,22 @@ export function createInvalidConfig(): string {
|
||||||
export function deleteConfigFile(configPath: string): void {
|
export function deleteConfigFile(configPath: string): void {
|
||||||
cleanupTempDir(path.dirname(configPath));
|
cleanupTempDir(path.dirname(configPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a temporary install-level client.json for --client-config tests.
|
||||||
|
*/
|
||||||
|
export function createClientConfigFile(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): 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));
|
||||||
|
}
|
||||||
|
|
|
||||||
188
clients/cli/__tests__/oauth-runner.test.ts
Normal file
188
clients/cli/__tests__/oauth-runner.test.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
220
clients/cli/package-lock.json
generated
220
clients/cli/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@napi-rs/keyring": "^1.3.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"atomically": "^2.1.1",
|
"atomically": "^2.1.1",
|
||||||
"commander": "^13.1.0",
|
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@napi-rs/keyring": "^1.3.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"atomically": "^2.1.1",
|
"atomically": "^2.1.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { awaitableLog } from "./utils/awaitable-log.js";
|
||||||
import type {
|
import type {
|
||||||
InspectorServerSettings,
|
InspectorServerSettings,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
InspectorClientEnvironment,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,6 +23,24 @@ import {
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
} from "@inspector/core/mcp/node/index.js";
|
} from "@inspector/core/mcp/node/index.js";
|
||||||
import type { JsonValue } from "@inspector/core/mcp/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 {
|
import {
|
||||||
LoggingLevelSchema,
|
LoggingLevelSchema,
|
||||||
type LoggingLevel,
|
type LoggingLevel,
|
||||||
|
|
@ -47,6 +66,9 @@ async function callMethod(
|
||||||
serverConfig: MCPServerConfig,
|
serverConfig: MCPServerConfig,
|
||||||
serverSettings: InspectorServerSettings | undefined,
|
serverSettings: InspectorServerSettings | undefined,
|
||||||
args: MethodArgs & { method: string },
|
args: MethodArgs & { method: string },
|
||||||
|
clientConfig: ClientConfig,
|
||||||
|
cliAuthOverrides?: RunnerClientConfigOverrides,
|
||||||
|
callbackUrlConfig?: RunnerOAuthCallbackConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const packageJsonPath = join(__dirname, "../package.json");
|
const packageJsonPath = join(__dirname, "../package.json");
|
||||||
|
|
@ -62,16 +84,36 @@ async function callMethod(
|
||||||
const version = packageJson.version;
|
const version = packageJson.version;
|
||||||
const clientIdentity = { name, 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, {
|
const inspectorClient = new InspectorClient(serverConfig, {
|
||||||
environment: {
|
environment,
|
||||||
transport: createTransportNode,
|
|
||||||
},
|
|
||||||
clientIdentity,
|
clientIdentity,
|
||||||
initialLoggingLevel: "debug",
|
initialLoggingLevel: "debug",
|
||||||
progress: false,
|
progress: false,
|
||||||
sample: false,
|
sample: false,
|
||||||
elicit: false,
|
elicit: false,
|
||||||
serverSettings,
|
serverSettings,
|
||||||
|
...clientAuthOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
let managedToolsState: ManagedToolsState | null = null;
|
let managedToolsState: ManagedToolsState | null = null;
|
||||||
|
|
@ -240,11 +282,16 @@ function parseKeyValuePair(
|
||||||
return { ...previous, [key as string]: parsedValue };
|
return { ...previous, [key as string]: parsedValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv?: string[]): {
|
async function parseArgs(argv?: string[]): Promise<{
|
||||||
serverConfig: MCPServerConfig;
|
serverConfig: MCPServerConfig;
|
||||||
serverSettings: InspectorServerSettings | undefined;
|
serverSettings: InspectorServerSettings | undefined;
|
||||||
methodArgs: MethodArgs & { method: string };
|
methodArgs: MethodArgs & { method: string };
|
||||||
} {
|
clientConfigPath?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
clientMetadataUrl?: string;
|
||||||
|
callbackUrl?: string;
|
||||||
|
}> {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
// On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead
|
// On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead
|
||||||
// of letting commander call process.exit(). The binary entry (index.ts) still
|
// 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)",
|
"Tool-specific metadata as key=value pairs (for tools/call method only)",
|
||||||
parseKeyValuePair,
|
parseKeyValuePair,
|
||||||
{},
|
{},
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--client-config <path>",
|
||||||
|
"Install-level client config (default: ~/.mcp-inspector/storage/client.json, or MCP_CLIENT_CONFIG_PATH)",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--client-id <id>",
|
||||||
|
"OAuth client ID (static client) for HTTP servers",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--client-secret <secret>",
|
||||||
|
"OAuth client secret (for confidential clients)",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--client-metadata-url <url>",
|
||||||
|
"OAuth Client ID Metadata Document URL (CIMD) for HTTP servers",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--callback-url <url>",
|
||||||
|
`OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
program.parse(preArgs);
|
program.parse(preArgs);
|
||||||
|
|
@ -390,6 +457,11 @@ function parseArgs(argv?: string[]): {
|
||||||
transport?: "sse" | "http" | "stdio";
|
transport?: "sse" | "http" | "stdio";
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
header?: Record<string, string>;
|
header?: Record<string, string>;
|
||||||
|
clientConfig?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
clientMetadataUrl?: string;
|
||||||
|
callbackUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serverOptions = {
|
const serverOptions = {
|
||||||
|
|
@ -410,7 +482,7 @@ function parseArgs(argv?: string[]): {
|
||||||
// Shared with the TUI: resolves the catalog/config source (or ad-hoc target),
|
// Shared with the TUI: resolves the catalog/config source (or ad-hoc target),
|
||||||
// enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into
|
// enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into
|
||||||
// per-server settings. `--server` selects one when the file has several.
|
// 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(
|
const { config: serverConfig, settings: serverSettings } = selectServerEntry(
|
||||||
entries,
|
entries,
|
||||||
options.server,
|
options.server,
|
||||||
|
|
@ -452,12 +524,37 @@ function parseArgs(argv?: string[]): {
|
||||||
serverConfig,
|
serverConfig,
|
||||||
serverSettings,
|
serverSettings,
|
||||||
methodArgs,
|
methodArgs,
|
||||||
|
clientConfigPath: options.clientConfig,
|
||||||
|
clientId: options.clientId,
|
||||||
|
clientSecret: options.clientSecret,
|
||||||
|
clientMetadataUrl: options.clientMetadataUrl,
|
||||||
|
callbackUrl: options.callbackUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCli(argv?: string[]): Promise<void> {
|
export async function runCli(argv?: string[]): Promise<void> {
|
||||||
const { serverConfig, serverSettings, methodArgs } = parseArgs(
|
const {
|
||||||
argv ?? process.argv,
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export default defineConfig({
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
// Bundle core source; leave npm deps external.
|
// Bundle core source; leave npm deps external.
|
||||||
noExternal: [/^@inspector\/core/],
|
noExternal: [/^@inspector\/core/],
|
||||||
|
external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino', 'zustand'],
|
||||||
esbuildOptions(options) {
|
esbuildOptions(options) {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
'@inspector/core': path.join(repoRoot, 'core'),
|
'@inspector/core': path.join(repoRoot, 'core'),
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,41 @@ Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL,
|
||||||
|
|
||||||
### TUI-specific (OAuth for HTTP servers)
|
### 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 |
|
#### OAuth callback URL
|
||||||
| ----------------------------- | ------------------------------------------------------------------------------------ |
|
|
||||||
| `--client-id <id>` | OAuth client ID (static client). |
|
The TUI starts a small loopback HTTP server to receive the authorization redirect after you sign in in the browser. Defaults:
|
||||||
| `--client-secret <secret>` | OAuth client secret (confidential clients). |
|
|
||||||
| `--client-metadata-url <url>` | OAuth Client ID Metadata Document URL (CIMD). |
|
| Surface | Default callback |
|
||||||
| `--callback-url <url>` | OAuth redirect/callback listener URL (default: `http://127.0.0.1:0/oauth/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 <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
|
||||||
|
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
|
||||||
|
| `--client-secret <secret>` | — | OAuth client secret (confidential clients); overrides `client.json`. |
|
||||||
|
| `--client-metadata-url <url>` | — | Client ID Metadata Document URL (CIMD); overrides `client.json`. |
|
||||||
|
| `--callback-url <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
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
939
clients/tui/__tests__/App.test.tsx
Normal file
939
clients/tui/__tests__/App.test.tsx
Normal file
|
|
@ -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<typeof render>;
|
||||||
|
|
||||||
|
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
|
||||||
|
vi.mock("ink-form", () => import("./helpers/inkFormMock.js"));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Controllable mock of the entire @inspector/core surface App.tsx depends on.
|
||||||
|
// `ctrl` is mutated by individual tests (reset in beforeEach) to drive what the
|
||||||
|
// hooks return and what the InspectorClient methods do.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const h = vi.hoisted(() => {
|
||||||
|
interface Ctrl {
|
||||||
|
status: string;
|
||||||
|
capabilities: Record<string, unknown> | null;
|
||||||
|
serverInfo: { name?: string; version?: string } | null;
|
||||||
|
instructions: string | null;
|
||||||
|
serverType: "stdio" | "sse" | "streamable-http";
|
||||||
|
oauthFlowState: unknown;
|
||||||
|
tools: unknown[];
|
||||||
|
resources: unknown[];
|
||||||
|
resourceTemplates: unknown[];
|
||||||
|
prompts: unknown[];
|
||||||
|
messages: unknown[];
|
||||||
|
fetchRequests: unknown[];
|
||||||
|
stderrLogs: unknown[];
|
||||||
|
}
|
||||||
|
const ctrl: Ctrl = {
|
||||||
|
status: "disconnected",
|
||||||
|
capabilities: null,
|
||||||
|
serverInfo: null,
|
||||||
|
instructions: null,
|
||||||
|
serverType: "stdio",
|
||||||
|
oauthFlowState: null,
|
||||||
|
tools: [],
|
||||||
|
resources: [],
|
||||||
|
resourceTemplates: [],
|
||||||
|
prompts: [],
|
||||||
|
messages: [],
|
||||||
|
fetchRequests: [],
|
||||||
|
stderrLogs: [],
|
||||||
|
};
|
||||||
|
const connect = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const disconnect = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const openUrl = vi.fn().mockResolvedValue(undefined);
|
||||||
|
// Shared OAuth-related spies so a test can configure resolve/reject and
|
||||||
|
// assert calls regardless of which per-server FakeClient instance App built.
|
||||||
|
const clientSpies = {
|
||||||
|
authenticate: vi.fn(
|
||||||
|
async (): Promise<string | undefined> => "https://auth.example/start",
|
||||||
|
),
|
||||||
|
clearOAuthTokens: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
|
||||||
|
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> | 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<typeof import("@inspector/core/auth/index.js")>();
|
||||||
|
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<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
alpha: {
|
||||||
|
config: { type: "stdio", command: "node", args: ["s.js"] },
|
||||||
|
} as never,
|
||||||
|
beta: {
|
||||||
|
config: { type: "stdio", command: "node", args: ["b.js"] },
|
||||||
|
} as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpServer(): Record<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
web: { config: { type: "streamable-http", url: "http://x" } } as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-server catalogs auto-select their only server on mount, so action
|
||||||
|
// tests can drive accelerators without first navigating the server list.
|
||||||
|
function oneStdio(): Record<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
alpha: {
|
||||||
|
config: { type: "stdio", command: "node", args: ["s.js"] },
|
||||||
|
} as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single streamable-http server catalog (auto-selected on mount).
|
||||||
|
function oneHttp(): Record<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
web: { config: { type: "streamable-http", url: "http://x" } } as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
|
||||||
|
// a stdio server <20> drives per-server tab gating + the tab-switch-away effects.
|
||||||
|
function httpThenStdio(): Record<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
web: { config: { type: "streamable-http", url: "http://x" } } as never,
|
||||||
|
cli: {
|
||||||
|
config: { type: "stdio", command: "node", args: ["s.js"] },
|
||||||
|
} as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stdioThenHttp(): Record<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
cli: {
|
||||||
|
config: { type: "stdio", command: "node", args: ["s.js"] },
|
||||||
|
} as never,
|
||||||
|
web: { config: { type: "streamable-http", url: "http://x" } } as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// An http server carrying saved settings (metadata, oauth creds, timeout) to
|
||||||
|
// exercise the per-server option-building branches in the mount effect.
|
||||||
|
function httpWithSettings(): Record<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
web: {
|
||||||
|
config: { type: "streamable-http", url: "http://x" },
|
||||||
|
settings: {
|
||||||
|
requestTimeout: 5000,
|
||||||
|
metadata: [
|
||||||
|
{ key: "team", value: "alpha" },
|
||||||
|
{ key: " ", value: "ignored" },
|
||||||
|
],
|
||||||
|
oauthClientId: "cid",
|
||||||
|
oauthClientSecret: "secret",
|
||||||
|
oauthScopes: "read write",
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realistic minimal fixtures for tab content / details modals.
|
||||||
|
const sampleTool = {
|
||||||
|
name: "alpha",
|
||||||
|
description: "Tool desc line1\nline2",
|
||||||
|
inputSchema: { type: "object", properties: { x: { type: "string" } } },
|
||||||
|
};
|
||||||
|
const sampleResource = {
|
||||||
|
name: "res1",
|
||||||
|
uri: "file://x",
|
||||||
|
description: "rdesc",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
};
|
||||||
|
const sampleTemplate = {
|
||||||
|
name: "tmpl1",
|
||||||
|
uriTemplate: "file://{id}",
|
||||||
|
description: "tdesc",
|
||||||
|
};
|
||||||
|
const promptWithArgs = {
|
||||||
|
name: "p1",
|
||||||
|
description: "pdesc",
|
||||||
|
arguments: [{ name: "arg1", description: "a1" }],
|
||||||
|
};
|
||||||
|
const promptNoName = { name: "", description: "no name prompt" };
|
||||||
|
const reqMessage = {
|
||||||
|
id: "m1",
|
||||||
|
direction: "request",
|
||||||
|
message: { jsonrpc: "2.0", id: 1, method: "tools/list" },
|
||||||
|
response: { jsonrpc: "2.0", id: 1, result: {} },
|
||||||
|
timestamp: new Date(0),
|
||||||
|
duration: 5,
|
||||||
|
};
|
||||||
|
const notifMessage = {
|
||||||
|
id: "m2",
|
||||||
|
direction: "notification",
|
||||||
|
message: { jsonrpc: "2.0", method: "notifications/message" },
|
||||||
|
timestamp: new Date(0),
|
||||||
|
};
|
||||||
|
const fullRequest = {
|
||||||
|
id: "r1",
|
||||||
|
method: "POST",
|
||||||
|
url: "http://x/mcp",
|
||||||
|
category: "transport",
|
||||||
|
responseStatus: 200,
|
||||||
|
responseStatusText: "OK",
|
||||||
|
duration: 12,
|
||||||
|
timestamp: new Date(0),
|
||||||
|
requestHeaders: { "content-type": "application/json" },
|
||||||
|
requestBody: JSON.stringify({ a: 1 }),
|
||||||
|
responseHeaders: { "x-h": "v" },
|
||||||
|
responseBody: JSON.stringify({ ok: true }),
|
||||||
|
};
|
||||||
|
const errorRequest = {
|
||||||
|
id: "r2",
|
||||||
|
method: "GET",
|
||||||
|
url: "http://x/auth",
|
||||||
|
category: "auth",
|
||||||
|
error: "boom",
|
||||||
|
timestamp: new Date(0),
|
||||||
|
requestHeaders: { accept: "*/*" },
|
||||||
|
requestBody: "not json{",
|
||||||
|
responseBody: "also not json{",
|
||||||
|
};
|
||||||
|
const respMessage = {
|
||||||
|
id: "m3",
|
||||||
|
direction: "response",
|
||||||
|
message: { jsonrpc: "2.0", id: 2, result: { ok: true } },
|
||||||
|
timestamp: new Date(0),
|
||||||
|
};
|
||||||
|
const bareRequest = {
|
||||||
|
id: "r3",
|
||||||
|
method: "GET",
|
||||||
|
url: "http://x/idle",
|
||||||
|
category: "transport",
|
||||||
|
timestamp: new Date(0),
|
||||||
|
requestHeaders: {},
|
||||||
|
};
|
||||||
|
const stderrLog = { timestamp: new Date(0), message: "log line" };
|
||||||
|
|
||||||
|
// Track rendered instances so each is unmounted after its test <20> concurrent
|
||||||
|
// mounted ink apps share raw-mode stdin handling and interfere with useInput.
|
||||||
|
const mounted: RenderResult[] = [];
|
||||||
|
|
||||||
|
function renderApp(servers: Record<string, TuiServer>) {
|
||||||
|
const r = render(
|
||||||
|
<App
|
||||||
|
mcpServers={servers}
|
||||||
|
clientConfig={emptyClientConfig}
|
||||||
|
callbackUrlConfig={callbackUrlConfig}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
mounted.push(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render and absorb ink-testing-library's intermittently-dropped first
|
||||||
|
* keypress with a benign no-op key ("x" is not bound while the server list is
|
||||||
|
* focused), so subsequent navigation keys register deterministically.
|
||||||
|
*/
|
||||||
|
async function mount(servers: Record<string, TuiServer>) {
|
||||||
|
const r = renderApp(servers);
|
||||||
|
await tick();
|
||||||
|
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 <EFBFBD> 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 <20> stable under load. */
|
||||||
|
async function expectFrame(r: RenderResult, substr: string) {
|
||||||
|
await waitForFrame(r, substr);
|
||||||
|
expect(r.lastFrame() ?? "").toContain(substr);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.assign(h.ctrl, {
|
||||||
|
status: "disconnected",
|
||||||
|
capabilities: null,
|
||||||
|
serverInfo: null,
|
||||||
|
instructions: null,
|
||||||
|
serverType: "stdio",
|
||||||
|
oauthFlowState: null,
|
||||||
|
tools: [],
|
||||||
|
resources: [],
|
||||||
|
resourceTemplates: [],
|
||||||
|
prompts: [],
|
||||||
|
messages: [],
|
||||||
|
fetchRequests: [],
|
||||||
|
stderrLogs: [],
|
||||||
|
});
|
||||||
|
h.connect.mockClear();
|
||||||
|
h.connect.mockResolvedValue(undefined);
|
||||||
|
h.disconnect.mockClear();
|
||||||
|
h.disconnect.mockResolvedValue(undefined);
|
||||||
|
h.openUrl.mockClear();
|
||||||
|
h.openUrl.mockResolvedValue(undefined);
|
||||||
|
h.cb.opts = null;
|
||||||
|
h.callbackStart.mockClear();
|
||||||
|
h.callbackStop.mockClear();
|
||||||
|
h.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("[Z");
|
||||||
|
await tick();
|
||||||
|
// no assertion on hidden focus state <20> exercising the branches
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Auth tab via the accelerator for an OAuth-capable server", async () => {
|
||||||
|
h.ctrl.serverType = "streamable-http";
|
||||||
|
const r = await mount(httpServer());
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await expectFrame(r, "OAuth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders connected status with capabilities", async () => {
|
||||||
|
h.ctrl.status = "connected";
|
||||||
|
h.ctrl.capabilities = { tools: {}, resources: {}, prompts: {} };
|
||||||
|
h.ctrl.serverInfo = { name: "srv", version: "1.0.0" };
|
||||||
|
const r = await mount(oneStdio());
|
||||||
|
await expectFrame(r, "connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("App (status, layout, modals)", () => {
|
||||||
|
it("renders the connecting status symbol/color", async () => {
|
||||||
|
h.ctrl.status = "connecting";
|
||||||
|
const r = await mount(oneStdio());
|
||||||
|
await expectFrame(r, "connecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the error status symbol/color", async () => {
|
||||||
|
h.ctrl.status = "error";
|
||||||
|
const r = await mount(oneStdio());
|
||||||
|
await expectFrame(r, "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error status when an http server has a 401 response logged", async () => {
|
||||||
|
h.ctrl.status = "error";
|
||||||
|
h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }];
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await expectFrame(r, "error");
|
||||||
|
await expectFrame(r, "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(
|
||||||
|
<App
|
||||||
|
mcpServers={oneHttp()}
|
||||||
|
clientConfig={emptyClientConfig}
|
||||||
|
callbackUrlConfig={callbackUrlConfig}
|
||||||
|
clientId="cid"
|
||||||
|
clientSecret="sec"
|
||||||
|
clientMetadataUrl="http://meta"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
mounted.push(r);
|
||||||
|
await tick();
|
||||||
|
await expectFrame(r, "MCP Servers");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("App (OAuth flows)", () => {
|
||||||
|
const unauthorized = Object.assign(new Error("request failed (401)"), {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs OAuth on connect when the server returns 401", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
|
||||||
|
h.clientSpies.authenticate.mockResolvedValue(undefined);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["c"]);
|
||||||
|
await waitUntil(() => h.callbackStart.mock.calls.length > 0);
|
||||||
|
expect(h.disconnect).toHaveBeenCalled();
|
||||||
|
expect(h.clientSpies.authenticate).toHaveBeenCalled();
|
||||||
|
expect(h.connect).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes OAuth when the callback fires during a 401 connect", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["c"]);
|
||||||
|
await waitUntil(() => h.cb.opts !== null);
|
||||||
|
await h.cb.opts!.onCallback({ code: "abc" });
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
244
clients/tui/__tests__/AuthTab.test.tsx
Normal file
244
clients/tui/__tests__/AuthTab.test.tsx
Normal file
|
|
@ -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<string, Set<() => 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(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
serverName={null}
|
||||||
|
inspectorClient={null}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(lastFrame() ?? "").toContain(
|
||||||
|
"Select a server to view authentication",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders OAuth details from getOAuthState", async () => {
|
||||||
|
const { client } = makeClient(sampleOAuthState);
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="authenticating"
|
||||||
|
oauthMessage={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(lastFrame() ?? "").toContain("Authenticating");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="error"
|
||||||
|
oauthMessage="Something went wrong"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
focused
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
onClearOAuth={onClearOAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
focused
|
||||||
|
connectionStatus="connected"
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
expect(lastFrame() ?? "").toContain("clear+disconnect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the focused footer when focused", async () => {
|
||||||
|
const { client } = makeClient(sampleOAuthState);
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
focused
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
height={80}
|
||||||
|
focused
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
clients/tui/__tests__/DetailsModal.test.tsx
Normal file
102
clients/tui/__tests__/DetailsModal.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<DetailsModal
|
||||||
|
title="Details"
|
||||||
|
content={<Text>some content</Text>}
|
||||||
|
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(
|
||||||
|
<DetailsModal
|
||||||
|
title="Details"
|
||||||
|
content={<Text>scrollable</Text>}
|
||||||
|
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(
|
||||||
|
<DetailsModal
|
||||||
|
title="Details"
|
||||||
|
content={<Text>x</Text>}
|
||||||
|
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(
|
||||||
|
<DetailsModal
|
||||||
|
title="Details"
|
||||||
|
content={<Text>x</Text>}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
stdin.write(ESC);
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
275
clients/tui/__tests__/HistoryTab.test.tsx
Normal file
275
clients/tui/__tests__/HistoryTab.test.tsx
Normal file
|
|
@ -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>): 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(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={[]}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={allMessages}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={[reqWithResponse]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={[reqPending]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={[respResult]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("Direction: response");
|
||||||
|
expect(frame).toContain("Response:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders notification details with a Notification label", () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={[notification]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={[unknownEntry]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(lastFrame() ?? "").toContain("Message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves selection with arrows and page keys when the list is focused", async () => {
|
||||||
|
const { lastFrame, stdin } = render(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={allMessages}
|
||||||
|
width={120}
|
||||||
|
height={12}
|
||||||
|
focusedPane="messages"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={allMessages}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
focusedPane="details"
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<HistoryTab
|
||||||
|
serverName="srv"
|
||||||
|
messages={allMessages}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
focusedPane="details"
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
modalOpen={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
stdin.write("+");
|
||||||
|
await tick();
|
||||||
|
expect(onViewDetails).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
401
clients/tui/__tests__/InfoTab.test.tsx
Normal file
401
clients/tui/__tests__/InfoTab.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<InfoTab
|
||||||
|
serverName={null}
|
||||||
|
serverConfig={null}
|
||||||
|
serverState={null}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={null}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={stdioConfig}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={config}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={withHeaders}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={config}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={config}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={config}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={stdioConfig}
|
||||||
|
serverState={state}
|
||||||
|
width={80}
|
||||||
|
height={30}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={stdioConfig}
|
||||||
|
serverState={state}
|
||||||
|
width={80}
|
||||||
|
height={30}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={stdioConfig}
|
||||||
|
serverState={state}
|
||||||
|
width={80}
|
||||||
|
height={30}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={null}
|
||||||
|
serverState={state}
|
||||||
|
width={80}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={null}
|
||||||
|
serverState={state}
|
||||||
|
width={80}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(lastFrame() ?? "").toContain("Error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the footer and handles scroll keys when focused", async () => {
|
||||||
|
const { lastFrame, stdin } = render(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={stdioConfig}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
focused
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const frame = lastFrame() ?? "";
|
||||||
|
expect(frame).toContain("to scroll");
|
||||||
|
|
||||||
|
// Drive every useInput branch
|
||||||
|
stdin.write("[A"); // up arrow
|
||||||
|
await tick();
|
||||||
|
stdin.write("[B"); // 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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={config}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName={null}
|
||||||
|
serverConfig={null}
|
||||||
|
serverState={null}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
focused
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoTab
|
||||||
|
serverName="my-server"
|
||||||
|
serverConfig={stdioConfig}
|
||||||
|
serverState={baseState}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(lastFrame() ?? "").not.toContain("to scroll");
|
||||||
|
});
|
||||||
|
});
|
||||||
184
clients/tui/__tests__/NotificationsTab.test.tsx
Normal file
184
clients/tui/__tests__/NotificationsTab.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<NotificationsTab stderrLogs={[]} width={80} height={20} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<NotificationsTab stderrLogs={logs} width={80} height={20} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<NotificationsTab
|
||||||
|
stderrLogs={[makeLog("a"), makeLog("b")]}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-invokes onCountChange when the log count changes", async () => {
|
||||||
|
const onCountChange = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<NotificationsTab
|
||||||
|
stderrLogs={[makeLog("a")]}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(onCountChange).toHaveBeenLastCalledWith(1);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<NotificationsTab
|
||||||
|
stderrLogs={[makeLog("a"), makeLog("b"), makeLog("c")]}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<NotificationsTab
|
||||||
|
stderrLogs={[makeLog("a")]}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
onCountChange={first}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(first).toHaveBeenLastCalledWith(1);
|
||||||
|
|
||||||
|
// New callback identity + new count → the ref is updated, then the
|
||||||
|
// count-change effect fires the latest callback.
|
||||||
|
rerender(
|
||||||
|
<NotificationsTab
|
||||||
|
stderrLogs={[makeLog("a"), makeLog("b")]}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
onCountChange={second}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
expect(second).toHaveBeenLastCalledWith(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without an onCountChange prop", () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<NotificationsTab
|
||||||
|
stderrLogs={[makeLog("solo")]}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<NotificationsTab stderrLogs={logs} width={80} height={20} focused />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<NotificationsTab stderrLogs={[]} width={80} height={20} focused />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<NotificationsTab stderrLogs={logs} width={80} height={20} />,
|
||||||
|
);
|
||||||
|
stdin.write(DOWN);
|
||||||
|
await tick();
|
||||||
|
expect(lastFrame() ?? "").toContain("line one");
|
||||||
|
});
|
||||||
|
});
|
||||||
344
clients/tui/__tests__/PromptTestModal.test.tsx
Normal file
344
clients/tui/__tests__/PromptTestModal.test.tsx
Normal file
|
|
@ -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> = {}): 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<string, string> | undefined) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete (globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__;
|
||||||
|
} else {
|
||||||
|
(globalThis as Record<string, unknown>).__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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={prompt}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt({ description: undefined })}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={null}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt({ arguments: undefined, description: undefined })}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
unmount();
|
||||||
|
await tick();
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
291
clients/tui/__tests__/PromptsTab.test.tsx
Normal file
291
clients/tui/__tests__/PromptsTab.test.tsx
Normal file
|
|
@ -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> = {}): 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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={[]}
|
||||||
|
inspectorClient={null}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={prompts}
|
||||||
|
inspectorClient={null}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={prompts}
|
||||||
|
inspectorClient={null}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
focusedPane="list"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={prompts}
|
||||||
|
inspectorClient={null}
|
||||||
|
width={120}
|
||||||
|
height={9}
|
||||||
|
focusedPane="list"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={prompts}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
focusedPane="list"
|
||||||
|
onFetchPrompt={onFetchPrompt}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={[noArgPrompt]}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
focusedPane="list"
|
||||||
|
onFetchPrompt={onFetchPrompt}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={[noArgPrompt]}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
focusedPane="list"
|
||||||
|
onFetchPrompt={vi.fn()}
|
||||||
|
onViewDetails={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={[noArgPrompt]}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
focusedPane="list"
|
||||||
|
onFetchPrompt={vi.fn()}
|
||||||
|
onViewDetails={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={prompts}
|
||||||
|
inspectorClient={null}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
focusedPane="details"
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<PromptsTab
|
||||||
|
prompts={prompts}
|
||||||
|
inspectorClient={inspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
focusedPane="list"
|
||||||
|
onFetchPrompt={onFetchPrompt}
|
||||||
|
modalOpen={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
stdin.write("\r");
|
||||||
|
await tick();
|
||||||
|
expect(onFetchPrompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
364
clients/tui/__tests__/RequestsTab.test.tsx
Normal file
364
clients/tui/__tests__/RequestsTab.test.tsx
Normal file
|
|
@ -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>): 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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[]}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RequestsTab serverName="srv" requests={[]} width={120} height={30} />,
|
||||||
|
);
|
||||||
|
expect(lastFrame() ?? "").toContain("Requests (0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the list with status colors, labels and durations", () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[
|
||||||
|
fullRequest,
|
||||||
|
errorRequest,
|
||||||
|
pendingRequest,
|
||||||
|
informationalRequest,
|
||||||
|
]}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[fullRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[errorRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[pendingRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(lastFrame() ?? "").toContain("Request in progress...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders raw (non-JSON) request and response bodies", () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[nonJsonRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[redirectRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(lastFrame() ?? "").toContain("301");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights the details header when the details pane is focused", () => {
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[fullRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
focusedPane="details"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={many}
|
||||||
|
width={120}
|
||||||
|
height={12}
|
||||||
|
focusedPane="requests"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={many}
|
||||||
|
width={120}
|
||||||
|
height={10}
|
||||||
|
focusedPane="requests"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[fullRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
focusedPane="details"
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[fullRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
focusedPane="details"
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
modalOpen={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
stdin.write("+");
|
||||||
|
await tick();
|
||||||
|
expect(onViewDetails).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores '+' when no onViewDetails handler is provided", async () => {
|
||||||
|
const { lastFrame, stdin } = render(
|
||||||
|
<RequestsTab
|
||||||
|
serverName="srv"
|
||||||
|
requests={[fullRequest]}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
focusedPane="details"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
stdin.write("+");
|
||||||
|
await tick();
|
||||||
|
// still rendered, no crash
|
||||||
|
expect(lastFrame() ?? "").toContain("GET https://example.com/oauth");
|
||||||
|
});
|
||||||
|
});
|
||||||
267
clients/tui/__tests__/ResourceTestModal.test.tsx
Normal file
267
clients/tui/__tests__/ResourceTestModal.test.tsx
Normal file
|
|
@ -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<Template> = {}): Template => ({
|
||||||
|
name: "Greeting",
|
||||||
|
uriTemplate: "greeting://{name}",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeClient = (readResourceFromTemplate: unknown): InspectorClient =>
|
||||||
|
({ readResourceFromTemplate }) as unknown as InspectorClient;
|
||||||
|
|
||||||
|
const setSubmitValue = (value: Record<string, unknown>) => {
|
||||||
|
(globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__ = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (globalThis as Record<string, unknown>).__INK_FORM_SUBMIT_VALUE__;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderAndSubmit = async (
|
||||||
|
client: InspectorClient | null,
|
||||||
|
template: Template = makeTemplate(),
|
||||||
|
submitValue: Record<string, unknown> = { name: "world" },
|
||||||
|
) => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const api = render(
|
||||||
|
<ResourceTestModal
|
||||||
|
template={template}
|
||||||
|
inspectorClient={client}
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
setSubmitValue(submitValue);
|
||||||
|
api.stdin.write("\r");
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
return { ...api, onClose };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ResourceTestModal", () => {
|
||||||
|
it("renders the form initially without invoking the client", async () => {
|
||||||
|
const read = vi.fn();
|
||||||
|
const api = render(
|
||||||
|
<ResourceTestModal
|
||||||
|
template={makeTemplate()}
|
||||||
|
inspectorClient={fakeClient(read)}
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
expect(read).not.toHaveBeenCalled();
|
||||||
|
api.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the template description when present", async () => {
|
||||||
|
const read = vi.fn();
|
||||||
|
const api = render(
|
||||||
|
<ResourceTestModal
|
||||||
|
template={makeTemplate({ description: "A friendly greeting" })}
|
||||||
|
inspectorClient={fakeClient(read)}
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
api.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the 'Unknown Template' label when the template has an empty name", async () => {
|
||||||
|
const read = vi.fn();
|
||||||
|
const api = render(
|
||||||
|
<ResourceTestModal
|
||||||
|
template={makeTemplate({ name: "" })}
|
||||||
|
inspectorClient={fakeClient(read)}
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
api.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the loading state while the read is in flight", async () => {
|
||||||
|
let resolveRead: (v: unknown) => void = () => {};
|
||||||
|
const pending = new Promise((resolve) => {
|
||||||
|
resolveRead = resolve;
|
||||||
|
});
|
||||||
|
const read = vi.fn().mockReturnValue(pending);
|
||||||
|
const api = render(
|
||||||
|
<ResourceTestModal
|
||||||
|
template={makeTemplate()}
|
||||||
|
inspectorClient={fakeClient(read)}
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
setSubmitValue({ name: "world" });
|
||||||
|
api.stdin.write("\r");
|
||||||
|
await tick();
|
||||||
|
// The component is now committed in the "loading" state (read not resolved).
|
||||||
|
expect(read).toHaveBeenCalled();
|
||||||
|
resolveRead({
|
||||||
|
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
|
||||||
|
expandedUri: "greeting://world",
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
api.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the resource and shows successful content", async () => {
|
||||||
|
const read = vi.fn().mockResolvedValue({
|
||||||
|
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
|
||||||
|
expandedUri: "greeting://world",
|
||||||
|
});
|
||||||
|
const { onClose, stdin, unmount } = await renderAndSubmit(
|
||||||
|
fakeClient(read),
|
||||||
|
makeTemplate(),
|
||||||
|
{ name: "world" },
|
||||||
|
);
|
||||||
|
expect(read).toHaveBeenCalledWith("greeting://{name}", { name: "world" });
|
||||||
|
// Drive scroll keys in results state for scrollBy / page coverage.
|
||||||
|
stdin.write(DOWN);
|
||||||
|
await tick();
|
||||||
|
stdin.write(UP);
|
||||||
|
await tick();
|
||||||
|
stdin.write(PAGE_DOWN);
|
||||||
|
await tick();
|
||||||
|
stdin.write(PAGE_UP);
|
||||||
|
await tick();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches an Error thrown by readResourceFromTemplate", async () => {
|
||||||
|
const read = vi.fn().mockRejectedValue(new Error("not found"));
|
||||||
|
const { unmount } = await renderAndSubmit(fakeClient(read));
|
||||||
|
expect(read).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a string thrown by readResourceFromTemplate", async () => {
|
||||||
|
const read = vi.fn().mockRejectedValue("boom");
|
||||||
|
const { unmount } = await renderAndSubmit(fakeClient(read));
|
||||||
|
expect(read).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches an object error carrying uri and message (uri + Object.assign branches)", async () => {
|
||||||
|
const read = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue({ message: "bad uri", uri: "greeting://expanded" });
|
||||||
|
const { unmount } = await renderAndSubmit(fakeClient(read));
|
||||||
|
expect(read).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches an object error without message or uri (Object.assign + Unknown error)", async () => {
|
||||||
|
const read = vi.fn().mockRejectedValue({ code: 42 });
|
||||||
|
const { unmount } = await renderAndSubmit(fakeClient(read));
|
||||||
|
expect(read).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a non-object, non-string throw (else branch)", async () => {
|
||||||
|
const read = vi.fn().mockRejectedValue(12345);
|
||||||
|
const { unmount } = await renderAndSubmit(fakeClient(read));
|
||||||
|
expect(read).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on submit when inspectorClient is null (early-return guard)", async () => {
|
||||||
|
const { onClose, unmount } = await renderAndSubmit(null);
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on ESC while in form state", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const api = render(
|
||||||
|
<ResourceTestModal
|
||||||
|
template={makeTemplate()}
|
||||||
|
inspectorClient={fakeClient(vi.fn())}
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
api.stdin.write(ESC);
|
||||||
|
await tick();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
api.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on ESC while in results state", async () => {
|
||||||
|
const read = vi.fn().mockResolvedValue({
|
||||||
|
result: { contents: [{ uri: "greeting://world", text: "hi" }] },
|
||||||
|
expandedUri: "greeting://world",
|
||||||
|
});
|
||||||
|
const { onClose, stdin, unmount } = await renderAndSubmit(fakeClient(read));
|
||||||
|
stdin.write(ESC);
|
||||||
|
await tick();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("responds to a stdout resize event", async () => {
|
||||||
|
const api = render(
|
||||||
|
<ResourceTestModal
|
||||||
|
template={makeTemplate()}
|
||||||
|
inspectorClient={fakeClient(vi.fn())}
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
process.stdout.emit("resize");
|
||||||
|
await tick();
|
||||||
|
api.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue