test(cli,tui): add in-process source coverage gates (#1484) (#1502)

* test(cli,tui): add in-process source coverage gates (#1484)

CLI and TUI source had no coverage gate: CLI tests spawned the built
binary out-of-process (coverage never saw clients/cli/src) and the TUI
had only two smoke tests. Regressions there were invisible to CI.

CLI:
- Rewrite __tests__/helpers/cli-runner.ts to invoke runCli() in-process
  (capture process.std*.write, map thrown errors to an exit code),
  preserving the CliResult shape so all existing tests run unchanged and
  clients/cli/src is now measured under v8 instrumentation.
- cli.ts: program.exitOverride() so a commander usage error throws
  (catchable in-process) instead of tearing down the worker; --help /
  --version still exit cleanly. Drop the dead, never-imported main().
- Add a thin out-of-process e2e.test.ts that spawns the built binary for
  the shebang / process.exit paths the in-process runner can't reach.
- Add @vitest/coverage-v8, a per-file gate (90/85/80/50) excluding only
  src/index.ts (binary bootstrap), and pool: 'forks' for capture
  isolation. cli.ts lands at ~98% lines / 100% functions.

TUI:
- Add @vitest/coverage-v8 and an interim per-file gate over the
  non-React logic (logger.ts, components/tabsConfig.ts, utils/*); the
  Ink components, App.tsx, and hooks/ are excluded with a documented
  follow-up (#1501). Add tests covering the included modules (100%).

Wiring + docs:
- CI runs each client's test:coverage; root test:cli / test:tui (used by
  validate) and root test:coverage now enforce the gates.
- gitignore clients/**/coverage; update AGENTS.md and client READMEs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): clarify in-process runner footguns from PR #1502 review

Review notes on the in-process CLI test runner (no behavior change):
- Warn in runCli's docblock that --help/--version must NOT go through the
  in-process runner: exitOverride only re-throws non-zero exits, so exitCode
  0 falls through to process.exit(0) and would kill the vitest worker. Those
  belong in the out-of-process e2e.test.ts layer.
- Document that a timeout orphans the uncancellable invokeCli promise (its
  stdio subprocess stays alive; later writes hit the restored stdout) — a
  known, acceptable trade-off on the hang-only failure path.
- Fix a stale reference to the removed main() (errors now route through
  index.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): fix remaining stale main() reference in vitest exclusion comment

PR #1502 re-review caught a second stale `main()` mention (the first was
fixed in 52c23e41). src/index.ts inlines the bootstrap as
runCli(process.argv).then(...).catch(handleError) — there is no main().
Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: compose root test:coverage from per-client scripts

Replace the brittle inline cd-chain with a composition of the existing
test:cli / test:tui scripts plus a new matching test:web. Behavior is
unchanged (web → cli → tui coverage gates) but there's a single source of
truth for each client's coverage invocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-20 15:05:58 -04:00 • committed by GitHub
commit 388ea5a7bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1304 additions and 197 deletions

View file

@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* Wait for pino's async file destination to actually create the log file.
* `pino.destination({ mkdir: true })` opens the stream asynchronously, so the
* file (and any parent dir) appears a tick later. Awaiting this before cleanup
* also guarantees the background mkdir/open has finished, so `afterEach`'s
* rmSync can't race it into an ENOENT.
*/
async function waitForFile(filePath: string, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!existsSync(filePath)) {
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for ${filePath}`);
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
/**
* `getTuiLogger` lazily builds a pino singleton that writes to a file. Each
* test gets a fresh module (vi.resetModules) and a temp log dir so the two
* directory-resolution branches can both be exercised without touching the
* real ~/.mcp-inspector.
*/
describe("getTuiLogger", () => {
const envKeys = ["MCP_INSPECTOR_LOG_DIR", "HOME", "USERPROFILE", "LOG_LEVEL"];
let saved: Record<string, string | undefined>;
let tempDir: string;
beforeEach(() => {
saved = Object.fromEntries(envKeys.map((k) => [k, process.env[k]]));
tempDir = mkdtempSync(join(tmpdir(), "tui-logger-test-"));
vi.resetModules();
});
afterEach(() => {
for (const key of envKeys) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
rmSync(tempDir, { recursive: true, force: true });
});
it("writes to MCP_INSPECTOR_LOG_DIR and returns a cached singleton", async () => {
process.env.MCP_INSPECTOR_LOG_DIR = tempDir;
const { getTuiLogger } = await import("../src/logger.js");
const first = getTuiLogger();
const second = getTuiLogger();
expect(first).toBe(second);
await waitForFile(join(tempDir, "auth.log"));
});
it("falls back to HOME/.mcp-inspector when MCP_INSPECTOR_LOG_DIR is unset", async () => {
delete process.env.MCP_INSPECTOR_LOG_DIR;
process.env.HOME = tempDir;
const { getTuiLogger } = await import("../src/logger.js");
const logger = getTuiLogger();
expect(logger).toBeDefined();
await waitForFile(join(tempDir, ".mcp-inspector", "auth.log"));
});
});

View file

@ -0,0 +1,27 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import open from "open";
import { openUrl } from "../src/utils/openUrl.js";
// `open` shells out to the OS browser opener — stub it so the test only checks
// that openUrl forwards the right string.
vi.mock("open", () => ({ default: vi.fn().mockResolvedValue(undefined) }));
const openMock = vi.mocked(open);
describe("openUrl", () => {
beforeEach(() => {
openMock.mockClear();
});
it("passes a string URL straight through", async () => {
await openUrl("https://example.com/auth");
expect(openMock).toHaveBeenCalledWith("https://example.com/auth");
});
it("serializes a URL object via .href", async () => {
await openUrl(new URL("https://example.com/callback?code=1"));
expect(openMock).toHaveBeenCalledWith(
"https://example.com/callback?code=1",
);
});
});

View file

@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { promptArgsToForm } from "../src/utils/promptArgsToForm.js";
describe("promptArgsToForm", () => {
it("returns an empty Parameters section when there are no arguments", () => {
const form = promptArgsToForm([], "noArgs");
expect(form.title).toBe("Get Prompt: noArgs");
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
});
it("maps prompt arguments to required string fields by default", () => {
const form = promptArgsToForm(
[
{ name: "topic", description: "What to write about" },
{ name: "tone", required: false },
],
"writer",
);
expect(form.sections[0]!.title).toBe("Prompt Arguments");
const [topic, tone] = form.sections[0]!.fields;
expect(topic).toMatchObject({
name: "topic",
type: "string",
required: true,
description: "What to write about",
});
// Only an explicit `required: false` makes a field optional.
expect(tone).toMatchObject({ name: "tone", required: false });
});
});

View file

@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { schemaToForm } from "../src/utils/schemaToForm.js";
describe("schemaToForm", () => {
it("returns an empty Parameters section when there is no schema", () => {
const form = schemaToForm(null, "noParams");
expect(form.title).toBe("Test Tool: noParams");
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
});
it("returns an empty Parameters section when properties are absent", () => {
const form = schemaToForm({}, "empty");
expect(form.sections[0]?.fields).toEqual([]);
});
it("maps each JSON Schema type to the matching ink-form field type", () => {
const form = schemaToForm(
{
properties: {
name: { type: "string", title: "Name" },
age: { type: "integer", minimum: 0, maximum: 120 },
ratio: { type: "number", minimum: 0, maximum: 1 },
active: { type: "boolean" },
mystery: { type: "object" },
},
required: ["name"],
},
"typed",
);
const fields = form.sections[0]!.fields;
const byName = Object.fromEntries(fields.map((f) => [f.name, f]));
expect(byName.name).toMatchObject({
type: "string",
label: "Name",
required: true,
});
expect(byName.age).toMatchObject({ type: "integer", min: 0, max: 120 });
expect(byName.ratio).toMatchObject({ type: "float", min: 0, max: 1 });
expect(byName.active).toMatchObject({ type: "boolean" });
// Unknown types fall back to string, and label falls back to the key.
expect(byName.mystery).toMatchObject({ type: "string", label: "mystery" });
});
it("builds a select field from an enum", () => {
const form = schemaToForm(
{ properties: { color: { type: "string", enum: ["red", "blue"] } } },
"enum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "red", value: "red" },
{ label: "blue", value: "blue" },
],
});
});
it("builds a select field from an array-of-enum", () => {
const form = schemaToForm(
{
properties: {
// The array branch is nested under the outer `enum` guard, so a
// top-level `enum` must also be present for it to be reached; the
// options are taken from `items.enum`.
tags: {
type: "array",
enum: ["a", "b"],
items: { enum: ["a", "b"] },
},
},
},
"arrayEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "a", value: "a" },
{ label: "b", value: "b" },
],
});
});
it("carries a JSON Schema default through as the field's initialValue", () => {
const form = schemaToForm(
{ properties: { greeting: { type: "string", default: "hi" } } },
"withDefault",
);
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" });
});
});

View file

@ -0,0 +1,35 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { uriTemplateToForm } from "../src/utils/uriTemplateToForm.js";
describe("uriTemplateToForm", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("creates a field for each template variable", () => {
const form = uriTemplateToForm("file:///{path}/{name}", "file");
expect(form.title).toBe("Read Resource: file");
const fields = form.sections[0]!.fields;
expect(fields.map((f) => f.name)).toEqual(["path", "name"]);
expect(fields[0]).toMatchObject({ type: "string", required: false });
});
it("returns an empty Template Variables section for a static URI", () => {
const form = uriTemplateToForm("file:///static", "static");
expect(form.sections[0]).toEqual({
title: "Template Variables",
fields: [],
});
});
it("logs and returns an empty form when the template cannot be parsed", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const form = uriTemplateToForm("file:///{unclosed", "broken");
expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse URI template:",
expect.any(Error),
);
expect(form.sections[0]!.fields).toEqual([]);
});
});