Three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and five on its hookTimeout/teardownTimeout: 10000, and all 788 Testing Library waits in the web unit project ran on asyncUtilTimeout: 1000. None of those numbers was chosen here, and all of them were sized for an idle machine rather than for three or four concurrent worktree sessions each running a full local:gate on eight cores. #2292, #2278, #2250, #1942 and #1742 were each that class, diagnosed one site at a time. This is the complement of #1596, not a retreat from it: every fix that issue produced stands, and no race is hidden here. What changes is that a correct, deterministic test is no longer cut off by a budget nobody picked. - vitest.shared.mts exports frozen TIMEOUTS / INTEGRATION_TIMEOUTS; all six projects spread one of them, clients/launcher importing the shared module for the first time to do so. - Both web projects configure Testing Library's asyncUtilTimeout. The storybook one needs its own setup file, deliberately outside .storybook/ and free of setProjectAnnotations so the automatic preview-annotation provisioning (#1898) is untouched. - 28 per-suite restatements of the integration project's own budget are gone, so raising the shared value now moves every site under it. The handful that genuinely differ — cross-process lock contention, a full OAuth round trip — keep their budget as a named constant that says why. - AppRenderer's waitFor no longer asks for exactly its enclosing budget, which it could never win (#2292's shape); cli's e2e child timer drops below the project budget so the bound carrying the diagnostic is the one that fires. - The transition auto-settle's slack term is named RAF_SLACK_MS and raised; the term derived from HEADER_ANIM_MS is untouched. The two answer to different things and must not be scaled as one number. - The 19 Playwright budgets scattered across three web smokes become four named constants in scripts/lib/browser-timeouts.mjs. scripts/verify-test-timeouts.mjs resolves each project through Vitest itself and asserts the budgets, the asyncUtilTimeout sites, and that retry stays unset. It was observed red against the pre-change config: unit/tui/launcher at testTimeout 5000, five projects at hookTimeout 10000, both asyncUtilTimeout sites unmet. Closes #2323 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp Signed-off-by: cliffhall <cliff@futurescale.com>
119 lines
4.4 KiB
TypeScript
119 lines
4.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.
|
|
*
|
|
* The budget is a ceiling, not a wait: the loop exits on the first `existsSync`
|
|
* that succeeds, so a passing run pays only the 20ms poll interval and the
|
|
* headroom is spent only when a real filesystem write is genuinely slow — which
|
|
* is what an oversubscribed machine produces (#2323). 2000ms was Vitest-era
|
|
* guesswork against an idle one.
|
|
*/
|
|
const FILE_APPEAR_TIMEOUT_MS = 10_000;
|
|
|
|
async function waitForFile(
|
|
filePath: string,
|
|
timeoutMs = FILE_APPEAR_TIMEOUT_MS,
|
|
): 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);
|
|
}
|
|
});
|
|
});
|