inspector/clients/tui/__tests__/logger.test.ts
Cliff Hall c96db0402e
test: raise per-file coverage gate to 90 across all clients (closes #1271) (#1532)
* test(web): audit branch-coverage outliers, raise per-file gate to 70 (#1271)

The web per-file coverage gate had drifted red: the recently-landed EMA
auth feature (core/auth/ema/*) and ClientSettings components shipped
undertested, and the prior 50% branch floor let business-logic branches
go untested. CI does not run the coverage gate, so this accumulated.

Add tests to bring every failing file above the gate and push branch
coverage well past 70 across the board, then raise the per-file branches
threshold 50 -> 70 in clients/web/vite.config.ts.

- EMA core (emaFlow, resourceContext, jwt, wire): 50-88% -> 86-100% branch
- EMA OIDC/transport (idpOidc, tokenEndpoint, transportProvider): -> 97-100%
- oauthManager / discovery: 73-83% -> 88-92%
- ClientSettingsModal/Form, ConnectionInfoContent: 0-88% -> 96-100%
- useClientSettingsDraft / useEmaIdpLoginState hooks: 20% -> 100%
- TaskStatusBadge / SortableServerCard outliers: 50% -> 100%

Coverable branches reached by mocking useComputedColorScheme (dark theme),
dnd-kit useSortable (isDragging), and fetch/OIDC paths. Remaining sub-100
branches are genuinely-unreachable defensive guards or happy-dom-inherent
(portal / useMediaQuery / typeof window) and documented in test comments.

No source files changed. Global web branch coverage 88.73% -> 90.63%.
AGENTS.md updated; cli/tui/launcher stay at 50 (out of scope).

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

* test: raise per-file coverage gate to 90/90/90/90 across all clients

Follows the #1271 web branch-coverage audit (50 -> 70) by raising the
whole per-file gate to >=90 on all four dimensions — lines, statements,
functions, branches — for web, cli, tui, and launcher (core/ is measured
under the web suite). A codebase-wide audit added real tests for every
outlier and brought global coverage up across the board.

Config: all four vitest/vite coverage configs set to 90/90/90/90.
Two pure-type modules (core/mcp/import/types.ts, clients/web/server/
types.ts) added to the web coverage exclude list, matching the existing
type-only exclusions.

Tests: ~50 test files extended/created across core (auth, ema, mcp,
remote, state, storage, react), web components/screens/server, and the
cli/tui clients. Highlights: full EMA OAuth flow + transport coverage,
inspectorClient/remote server backfill, every Mantine control's handler
branches, and the cli unsupported-method / argv-fallback paths.

Genuinely-unreachable branches are annotated at the source with justified
`/* v8 ignore -- <reason> */` comments rather than lowering the gate:
happy-dom-inherent paths (Mantine portal, useMediaQuery, typeof window
SSR guards), React StrictMode effect replay, and provably-dead defensive
guards (?? fallbacks the types make impossible, Select.onChange values
outside the allowed list). No runtime logic changed; the only structural
edits are two behavior-preserving refactors (ResourceTemplatePanel hoists
the field value to a local so the ignore applies in JSX; ToolsScreen
swaps a ternary for an if) and prettier line-wrapping.

Also fixed a flaky real-timer debounce assertion in PromptArgumentsForm
(asserted an exact sibling-typing interleaving that raced the 300ms
debounce; now asserts the load-bearing fact — a non-empty fire-time
sibling capture).

AGENTS.md updated to describe the uniform 90 gate and the v8-ignore
policy. All four client coverage gates, root validate, storybook (370),
and smoke pass.

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

* test(web): make connect-crash SSE reader stop on the terminal event

`readSseEvents` looped until the server closed the stream (`done`). The
server closes the events stream immediately after writing the terminal
`transport_error`, but under the heavy parallel scheduler pressure of a
full `coverage` run (v8 instrumentation across all workers) that close
can lag far enough behind the event to trip the 30s test timeout — a
hang, not a wrong result. This made the test intermittently fail under
`npm run coverage` (it passed in isolation and under CI's lighter
`test:integration`).

Stop reading as soon as the terminal `transport_error` event arrives
(cancelling the reader) instead of waiting only for `done`. That event is
always the last one the session queue emits, so nothing is lost, and the
test no longer depends on stream-close timing. The `done` break is kept
for any stream that closes first.

Verified green across two consecutive full `npm run test:coverage` runs
(3094 tests) where it previously hung, plus isolation (~0.5s).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:26:03 -04:00

108 lines
4 KiB
TypeScript

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"));
});
it("falls back to USERPROFILE/.mcp-inspector when HOME is unset", async () => {
// Exercises the second arm of `HOME || USERPROFILE || "."`: HOME empty so
// the chain continues to USERPROFILE (the Windows home var).
delete process.env.MCP_INSPECTOR_LOG_DIR;
delete process.env.HOME;
process.env.USERPROFILE = tempDir;
const { getTuiLogger } = await import("../src/logger.js");
const logger = getTuiLogger();
expect(logger).toBeDefined();
await waitForFile(join(tempDir, ".mcp-inspector", "auth.log"));
});
it('falls back to "." when neither HOME nor USERPROFILE is set', async () => {
// Exercises the final arm of the chain: with both home vars unset, the dir
// resolves to "./.mcp-inspector" relative to the process cwd. chdir into the
// temp dir so the relative path pino opens lands there (and gets cleaned up).
delete process.env.MCP_INSPECTOR_LOG_DIR;
delete process.env.HOME;
delete process.env.USERPROFILE;
const originalCwd = process.cwd();
process.chdir(tempDir);
// Re-read cwd after chdir: on macOS tmpdir is a symlink (/var -> /private/var),
// so the resolved cwd differs from `tempDir` and the file lands under it.
const resolvedCwd = process.cwd();
try {
const { getTuiLogger } = await import("../src/logger.js");
const logger = getTuiLogger();
expect(logger).toBeDefined();
// pino resolves the relative "./.mcp-inspector/auth.log" against cwd.
await waitForFile(join(resolvedCwd, ".mcp-inspector", "auth.log"));
} finally {
process.chdir(originalCwd);
}
});
});