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>
271 lines
8.1 KiB
TypeScript
271 lines
8.1 KiB
TypeScript
/**
|
|
* 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";
|
|
import { join } from "node:path";
|
|
import {
|
|
TestServerHttp,
|
|
waitForOAuthWellKnown,
|
|
getDefaultServerConfig,
|
|
createOAuthTestServerConfig,
|
|
clearOAuthTestData,
|
|
} from "@modelcontextprotocol/inspector-test-server";
|
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
|
import { createTransportNode } from "@inspector/core/mcp/node/index.js";
|
|
import {
|
|
CallbackNavigation,
|
|
MutableRedirectUrlProvider,
|
|
} from "@inspector/core/auth/index.js";
|
|
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
|
|
import {
|
|
connectInspectorWithOAuth,
|
|
withCliAuthRecoveryRetry,
|
|
} from "../src/cliOAuth.js";
|
|
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
|
import { makeFakeServerSettings } from "./helpers/oauth-test-fakes.js";
|
|
|
|
const oauthTestStatePath = join(
|
|
tmpdir(),
|
|
`mcp-oauth-${process.pid}-cli-interactive.json`,
|
|
);
|
|
|
|
async function completeOAuthAuthorization(
|
|
authorizationUrl: URL,
|
|
): Promise<{ code: string; iss?: string }> {
|
|
let response = await fetch(authorizationUrl.toString(), {
|
|
redirect: "manual",
|
|
});
|
|
|
|
// Local composable test AS shows an HTML consent page on GET; approve via POST.
|
|
if (response.status === 200) {
|
|
const body = new URLSearchParams(authorizationUrl.searchParams);
|
|
response = await fetch(
|
|
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
redirect: "manual",
|
|
},
|
|
);
|
|
}
|
|
|
|
if (response.status !== 302 && response.status !== 301) {
|
|
throw new Error(
|
|
`Expected redirect (302/301), got ${response.status}: ${await response.text()}`,
|
|
);
|
|
}
|
|
const location = response.headers.get("location");
|
|
if (!location) {
|
|
throw new Error("Missing Location header");
|
|
}
|
|
const redirect = new URL(location, authorizationUrl.origin);
|
|
const code = redirect.searchParams.get("code");
|
|
if (!code) {
|
|
throw new Error("Missing authorization code");
|
|
}
|
|
const iss = redirect.searchParams.get("iss") ?? undefined;
|
|
return iss ? { code, iss } : { code };
|
|
}
|
|
|
|
function createAutoCompleteNavigation(
|
|
redirectUrlProvider: MutableRedirectUrlProvider,
|
|
) {
|
|
return new CallbackNavigation(async (url) => {
|
|
const { code, iss } = await completeOAuthAuthorization(url);
|
|
const redirect = redirectUrlProvider.redirectUrl;
|
|
if (!redirect) {
|
|
throw new Error("redirectUrl not set");
|
|
}
|
|
const callback = new URL(redirect);
|
|
callback.searchParams.set("code", code);
|
|
if (iss) callback.searchParams.set("iss", iss);
|
|
await fetch(callback.href);
|
|
});
|
|
}
|
|
|
|
const callbackUrlConfig = {
|
|
hostname: "127.0.0.1",
|
|
port: 6276,
|
|
pathname: "/oauth/callback",
|
|
};
|
|
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;
|
|
|
|
afterEach(async () => {
|
|
if (mcpServer) {
|
|
await mcpServer.stop();
|
|
mcpServer = null;
|
|
}
|
|
clearOAuthTestData();
|
|
try {
|
|
rmSync(oauthTestStatePath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, 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,
|
|
}),
|
|
};
|
|
|
|
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: {},
|
|
},
|
|
);
|
|
|
|
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();
|
|
},
|
|
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,
|
|
);
|
|
});
|