fix: state every test-gate timeout instead of inheriting a library default
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>
This commit is contained in:
parent
336e045210
commit
58e53d5a92
3 changed files with 1276 additions and 394 deletions
|
|
@ -7,6 +7,17 @@ import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-se
|
|||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const BIN = resolve(here, "../build/index.js");
|
||||
|
||||
/**
|
||||
* In-test bound on the spawned child, deliberately BELOW the cli project's own
|
||||
* `testTimeout` (15000, from `vitest.shared.mts`). The two used to be equal, so
|
||||
* which one fired was arbitrary — and they do different things: this one kills
|
||||
* the child's process group and rejects with a message naming the CLI, while
|
||||
* the project budget just fails the test with a generic timeout and leaves a
|
||||
* detached `node` behind. The useful diagnostic is here, so this is the bound
|
||||
* that must win (#2323).
|
||||
*/
|
||||
const CHILD_TIMEOUT_MS = 12_000;
|
||||
|
||||
interface SpawnResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
|
|
@ -36,7 +47,7 @@ function spawnCli(args: string[]): Promise<SpawnResult> {
|
|||
child.kill("SIGTERM");
|
||||
}
|
||||
reject(new Error("E2E CLI timed out"));
|
||||
}, 15000);
|
||||
}, CHILD_TIMEOUT_MS);
|
||||
child.stdout.on("data", (d) => (stdout += d.toString()));
|
||||
child.stderr.on("data", (d) => (stderr += d.toString()));
|
||||
child.on("error", (err) => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
/**
|
||||
* Interactive OAuth against a real HTTP test server.
|
||||
*
|
||||
* Every test here raises past the cli project's shared 15s budget, and is meant
|
||||
* to: each boots a server, waits for its well-known documents, runs a full
|
||||
* authorization round trip, then disconnects. That is elapsed work, not slack
|
||||
* for a loaded machine, so it stays stated per test rather than folding into
|
||||
* the shared value (#2323).
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
@ -90,6 +99,9 @@ const callbackUrlConfig = {
|
|||
};
|
||||
const presetRedirectUrl = "http://127.0.0.1:6276/oauth/callback";
|
||||
|
||||
/** A full interactive OAuth round trip against a freshly booted server. */
|
||||
const OAUTH_ROUND_TRIP_MS = 30_000;
|
||||
|
||||
describe("CLI interactive OAuth (integration)", () => {
|
||||
let mcpServer: TestServerHttp | null = null;
|
||||
|
||||
|
|
@ -104,148 +116,156 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 30_000);
|
||||
}, OAUTH_ROUND_TRIP_MS);
|
||||
|
||||
it("connects to an OAuth-protected server via the loopback callback server", async () => {
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportDCR: true,
|
||||
}),
|
||||
};
|
||||
it(
|
||||
"connects to an OAuth-protected server via the loopback callback server",
|
||||
async () => {
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportDCR: true,
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||
redirectUrlProvider.redirectUrl = presetRedirectUrl;
|
||||
const navigation = createAutoCompleteNavigation(redirectUrlProvider);
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
navigation,
|
||||
redirectUrlProvider,
|
||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||
redirectUrlProvider.redirectUrl = presetRedirectUrl;
|
||||
const navigation = createAutoCompleteNavigation(redirectUrlProvider);
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
navigation,
|
||||
redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
directAuthRecovery: true,
|
||||
oauth: {},
|
||||
},
|
||||
directAuthRecovery: true,
|
||||
oauth: {},
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
undefined,
|
||||
{ isTTY: true },
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.length).toBeGreaterThan(0);
|
||||
await client.disconnect();
|
||||
}, 30_000);
|
||||
|
||||
it("retries an RPC after step-up authorization when the user confirms", async () => {
|
||||
const baseConfig = getDefaultServerConfig();
|
||||
|
||||
const serverConfig = {
|
||||
...baseConfig,
|
||||
serverType: "streamable-http" as const,
|
||||
tools: baseConfig.tools!.map((tool) =>
|
||||
tool.name === "get_temp"
|
||||
? { ...tool, requiredScopes: ["weather:read"] }
|
||||
: tool,
|
||||
),
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
supportDCR: true,
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||
redirectUrlProvider.redirectUrl = presetRedirectUrl;
|
||||
const navigation = createAutoCompleteNavigation(redirectUrlProvider);
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
navigation,
|
||||
redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
directAuthRecovery: true,
|
||||
oauth: {
|
||||
scope: "mcp tools:read",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
undefined,
|
||||
{ isTTY: true },
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
const getTempTool = tools.tools.find((tool) => tool.name === "get_temp");
|
||||
expect(getTempTool).toBeDefined();
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
try {
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
makeFakeServerSettings(),
|
||||
() =>
|
||||
client.callTool(getTempTool!, {
|
||||
city: "NYC",
|
||||
units: "C",
|
||||
}),
|
||||
{ confirmStepUp: async () => true, isTTY: true },
|
||||
undefined,
|
||||
{ isTTY: true },
|
||||
);
|
||||
|
||||
const stepUpPrompted = stderrSpy.mock.calls.some(
|
||||
([chunk]) =>
|
||||
typeof chunk === "string" &&
|
||||
chunk.includes("Proceed with step-up authorization?"),
|
||||
);
|
||||
expect(stepUpPrompted).toBe(true);
|
||||
expect(result.success).toBe(true);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.length).toBeGreaterThan(0);
|
||||
await client.disconnect();
|
||||
}
|
||||
}, 30_000);
|
||||
},
|
||||
OAUTH_ROUND_TRIP_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"retries an RPC after step-up authorization when the user confirms",
|
||||
async () => {
|
||||
const baseConfig = getDefaultServerConfig();
|
||||
|
||||
const serverConfig = {
|
||||
...baseConfig,
|
||||
serverType: "streamable-http" as const,
|
||||
tools: baseConfig.tools!.map((tool) =>
|
||||
tool.name === "get_temp"
|
||||
? { ...tool, requiredScopes: ["weather:read"] }
|
||||
: tool,
|
||||
),
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
supportDCR: true,
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||
redirectUrlProvider.redirectUrl = presetRedirectUrl;
|
||||
const navigation = createAutoCompleteNavigation(redirectUrlProvider);
|
||||
const client = new InspectorClient(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: `${serverUrl}/mcp`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
navigation,
|
||||
redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
directAuthRecovery: true,
|
||||
oauth: {
|
||||
scope: "mcp tools:read",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
undefined,
|
||||
{ isTTY: true },
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
const getTempTool = tools.tools.find((tool) => tool.name === "get_temp");
|
||||
expect(getTempTool).toBeDefined();
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
try {
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
client,
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
makeFakeServerSettings(),
|
||||
() =>
|
||||
client.callTool(getTempTool!, {
|
||||
city: "NYC",
|
||||
units: "C",
|
||||
}),
|
||||
{ confirmStepUp: async () => true, isTTY: true },
|
||||
);
|
||||
|
||||
const stepUpPrompted = stderrSpy.mock.calls.some(
|
||||
([chunk]) =>
|
||||
typeof chunk === "string" &&
|
||||
chunk.includes("Proceed with step-up authorization?"),
|
||||
);
|
||||
expect(stepUpPrompted).toBe(true);
|
||||
expect(result.success).toBe(true);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
await client.disconnect();
|
||||
}
|
||||
},
|
||||
OAUTH_ROUND_TRIP_MS,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { vitestSharedPaths } from "../../vitest.shared.mts";
|
||||
import { TIMEOUTS, vitestSharedPaths } from "../../vitest.shared.mts";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { projectResolve } = vitestSharedPaths(dirname);
|
||||
|
|
@ -13,7 +13,9 @@ export default defineConfig({
|
|||
environment: "node",
|
||||
include: ["__tests__/**/*.test.ts"],
|
||||
setupFiles: ["__tests__/helpers/mock-open-url.ts"],
|
||||
testTimeout: 15000,
|
||||
// Shared budgets (#2323). `testTimeout` was already 15000 here by hand;
|
||||
// the hook and teardown budgets were Vitest's defaults until now.
|
||||
...TIMEOUTS,
|
||||
// The in-process runner (__tests__/helpers/cli-runner.ts) patches
|
||||
// process.std{out,err}.write to capture CLI output. Test files run in
|
||||
// separate forked processes (and tests within a file run sequentially), so
|
||||
|
|
|
|||
Loading…
Reference in a new issue