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:
cliffhall 2026-09-12 08:32:34 -04:00
commit 58e53d5a92
37 changed files with 1276 additions and 394 deletions

View file

@ -87,6 +87,22 @@ versions. **Align the versions** — bump it in every install that declares it.
Do not raise the heap with `--max-old-space-size`; that hides the class rather
than fixing it.
### `verify:test-timeouts`
A Vitest project resolves to a wall-clock budget nobody stated, a web setup
file stopped configuring Testing Library's `asyncUtilTimeout`, or someone added
a `retry`. The shared values live in `vitest.shared.mts` (`TIMEOUTS` /
`INTEGRATION_TIMEOUTS`) and every project spreads one of them — so **raise a
budget there**, not with a per-suite `}, 30_000)` argument, which only moves the
one site and leaves every future file on the default. A per-suite raise is
right only where the work is genuinely different (real cross-process lock
contention, a full OAuth round trip); say so at the site. `retry` stays unset:
it turns a load-induced red into a silent green on the only pre-push gate here.
A **failing test** is a different problem from a budget — read the failure
before reaching for a number. An assertion that races is #1596's class and is
fixed with fake timers or an awaited condition, not with headroom.
### `lint`
**There is no warning tier** — every `lint` script runs `--max-warnings 0`, so a

View file

@ -396,6 +396,50 @@ diagnose a failing gate — is the `testing` skill. These are the rules.
- **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it.
- **The web coverage `include` is a whitelist.** It names `components`/`hooks`/`theme`/`lib`/`utils`/`server` plus the browser-consumed `core/*` runtime, so a module placed **outside** those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The documented exceptions — `src/App.tsx` and the `src/main.tsx` / `src/index.ts` bootstraps — are called out in a comment on the `include` array itself.
### Test-gate timeouts are chosen values, stated once
**Every wall-clock budget a test gate runs under is a value somebody picked,
and it lives in one place (#2323).** The shared budgets are `TIMEOUTS` and
`INTEGRATION_TIMEOUTS` in `vitest.shared.mts`; all six Vitest projects spread
one of them, and Testing Library's `asyncUtilTimeout` — which no Vitest config
can see — is configured in each web project's setup file
(`src/test/setup.ts`, `src/test/storybookSetup.ts`).
`npm run verify:test-timeouts` resolves each project through Vitest itself and
enforces all of it.
This is not a licence to relax the bar. #1596 settled that: **a test that races
is fixed with fake timers or an awaited condition, never with headroom.** The
complementary case is what these budgets exist for — a test that is correct and
deterministic and is simply cut off, because the number bounding it was a
library default sized for an idle machine rather than for the one this repo is
worked on (three or four concurrent agent sessions in separate worktrees, each
free to run a full `local:gate`). Raising a budget nobody chose hides no race.
- **Raise a budget in the shared object, not at a suite.** A per-suite
`}, 30_000)` moves one site and leaves every future file where it was — which
is how five of the six projects came to have no stated budget at all. Delete a
restatement of the project's own value rather than keeping it in sync.
- **A per-suite raise is right only where the work is genuinely different**, and
then it says so: real cross-process lock contention (`file-lock.test.ts`), a
full interactive OAuth round trip (`oauth-interactive.test.ts`). Name the
constant and state the reason at the site.
- **Never nest a budget inside an equal-or-larger one.** `waitFor({ timeout: N })`
inside a test whose own budget is `N` can never win, and it reports as a
timeout naming neither the wait nor its subject (#2292). Two bounds on the
same work must differ, and the one carrying the useful diagnostic must be the
tighter (`clients/cli/__tests__/e2e.test.ts`).
- **`retry` stays unset.** A retry turns a load-induced red into a silent green
on the only pre-push gate this repo has, and hides a real race behind a second
attempt. The guard enforces this.
- **Do not scale a fixed sleep.** A `setTimeout(r, N)` with no condition is not
a timeout: it always waits the full window, so raising it slows every passing
run and still races on a loaded one. Replace one with a condition wait when it
actually flakes (#2250). The single exception is the transition auto-settle in
`clients/web/src/test/renderWithMantine.tsx`, whose failure is silent and
displaced rather than loud and local — and there only its `RAF_SLACK_MS` term
moves, never the term derived from the component's own animation constant.
## Mandatory pre-push gate
- **ALWAYS run `npm run format` before committing.** The **root** `format` auto-fixes `core/`, the root `scripts/` tooling, the root shared surface, and every client's scope in one shot. `validate` runs the non-fixing `format:check` and will fail in CI on any unformatted file, so run the auto-fixer first rather than letting `format:check` catch it.

View file

@ -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) => {

View file

@ -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,
);
});

View file

@ -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

View file

@ -6,7 +6,15 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
// Wide enough to contain `vitest.shared.mts`, which `vitest.config.ts`
// imports for the shared test budgets (#2323). `rootDir` only shapes error
// reporting under `noEmit`, and the emitting build config next door keeps
// its own `./src` — this project compiles nothing. `cli` and `tui` reach
// the same file with no `rootDir` at all, which infers the same thing.
"rootDir": "../..",
// Same import: `.mts` in a specifier needs this, and it is safe for the
// same reason — nothing here is emitted. `cli` and `tui` both set it.
"allowImportingTsExtensions": true,
"types": ["node"]
},
// `vitest.config.ts` lives here (not the src project) because the build

View file

@ -1,10 +1,17 @@
import { defineConfig } from "vitest/config";
import { TIMEOUTS } from "../../vitest.shared.mts";
export default defineConfig({
test: {
globals: false,
environment: "node",
include: ["__tests__/**/*.test.ts"],
// Shared budgets (#2323). This project holds a single test file, which is
// exactly why it is pinned rather than left on the defaults: it is cheap to
// include and expensive to remember when a second file arrives. This import
// is the only thing this config takes from the shared module — the resolve
// aliases are for clients that load React or `core/`.
...TIMEOUTS,
coverage: {
provider: "v8",
reporter: ["text", "html", "json-summary"],

View file

@ -27,11 +27,23 @@ const tick = async () => {
await new Promise((resolve) => setTimeout(resolve, 4));
};
/** Poll the frame until it contains `substr` — stable under coverage load. */
/**
* Poll the frame until it contains `substr` — stable under coverage load.
*
* The default budget (`tries` x the ~32ms `tick` below) matches `App.test.tsx`'s
* `POLL_TRIES`, which was raised twice for exactly this shape (#1742, #1942).
* It used to be 25 here — one third of the same renderer's budget under the
* same v8 instrumentation, for no stated reason, which made this the next one
* to go red. A high ceiling costs a passing assertion nothing: the loop returns
* the instant the predicate holds and only the slow path ever spends it
* (#2323).
*/
const POLL_TRIES = 100;
async function waitForFrame(
getFrame: () => string | undefined,
substr: string,
tries = 25,
tries = POLL_TRIES,
) {
for (let i = 0; i < tries; i++) {
if ((getFrame() ?? "").includes(substr)) return;

View file

@ -9,8 +9,19 @@ import { join } from "node:path";
* 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.
*/
async function waitForFile(filePath: string, timeoutMs = 2000): Promise<void> {
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) {

View file

@ -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,6 +13,10 @@ export default defineConfig({
environment: "node",
include: ["__tests__/**/*.test.{ts,tsx}"],
setupFiles: ["./__tests__/setup.ts"],
// Shared budgets (#2323). Ink renders through React, so every assertion
// here waits on a React commit under v8 instrumentation — the shape behind
// the poll-budget raises in #1742 and #1942.
...TIMEOUTS,
coverage: {
provider: "v8",
reporter: ["text", "html", "json-summary"],

View file

@ -4295,59 +4295,58 @@ describe("App dedupes concurrent OAuth clears that share a storage key (#2217)",
);
}
// Three full modal interaction sequences against the whole App tree, so this
// one runs long enough to trip the 5s default when the suite is under load.
it(
"suppresses a second clear for another entry with the same URL, and allows one after it settles",
{ timeout: 20000 },
async () => {
// `delay: null` drops userEvent's inter-event waits, which dominate here.
const user = userEvent.setup({ delay: null });
renderWithMantine(<App />);
// Three full modal interaction sequences against the whole App tree. This
// used to carry `{ timeout: 20000 }` because the project ran on Vitest's 5s
// default and this test tripped it under load; the project now states 15000
// itself (#2323), so the raise has nothing left to say that the shared value
// does not.
it("suppresses a second clear for another entry with the same URL, and allows one after it settles", async () => {
// `delay: null` drops userEvent's inter-event waits, which dominate here.
const user = userEvent.setup({ delay: null });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// The instances are typed `EventTarget`; an intersection names the
// test-only spy without erasing that (Copilot).
const client = clientInstances[0] as EventTarget & {
clearOAuthTokens: ReturnType<typeof vi.fn>;
};
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// The instances are typed `EventTarget`; an intersection names the
// test-only spy without erasing that (Copilot).
const client = clientInstances[0] as EventTarget & {
clearOAuthTokens: ReturnType<typeof vi.fn>;
};
// Hold the first clear open, as a pending RFC 7009 request would.
let settle: (v: { status: string; reason: string }) => void = () => {};
client.clearOAuthTokens.mockImplementation(
() =>
new Promise((resolve) => {
settle = resolve as typeof settle;
}),
);
// Hold the first clear open, as a pending RFC 7009 request would.
let settle: (v: { status: string; reason: string }) => void = () => {};
client.clearOAuthTokens.mockImplementation(
() =>
new Promise((resolve) => {
settle = resolve as typeof settle;
}),
);
await clearFromSettings(user, "open-settings");
await waitFor(() =>
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1),
);
await clearFromSettings(user, "open-settings");
await waitFor(() =>
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1),
);
// Entry B: a different catalog id, the same OAuth storage key. Both clears
// route through the live client precisely because they share that key, so
// an id-keyed guard would let this second one straight through.
await clearFromSettings(user, "open-settings-b");
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1);
// Entry B: a different catalog id, the same OAuth storage key. Both clears
// route through the live client precisely because they share that key, so
// an id-keyed guard would let this second one straight through.
await clearFromSettings(user, "open-settings-b");
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1);
// Suppression is for the duration of the in-flight clear only — the key
// must be released when it settles, or the control is dead for the rest of
// the session.
await act(async () => {
settle({ status: "skipped", reason: "no_endpoint" });
await Promise.resolve();
});
client.clearOAuthTokens.mockResolvedValue({
status: "skipped",
reason: "no_endpoint",
});
await clearFromSettings(user, "open-settings-b");
await waitFor(() =>
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(2),
);
},
);
// Suppression is for the duration of the in-flight clear only — the key
// must be released when it settles, or the control is dead for the rest of
// the session.
await act(async () => {
settle({ status: "skipped", reason: "no_endpoint" });
await Promise.resolve();
});
client.clearOAuthTokens.mockResolvedValue({
status: "skipped",
reason: "no_endpoint",
});
await clearFromSettings(user, "open-settings-b");
await waitFor(() =>
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(2),
);
});
});

View file

@ -557,19 +557,17 @@ describe("AppRenderer", () => {
// budget that is too tight rather than of a broken observer. Raised
// rather than retried: a `waitFor` that is generous costs nothing on the
// passing path, since it returns as soon as the assertion holds.
await waitFor(
() =>
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
expect.objectContaining({
theme: "dark",
styles: expect.objectContaining({
variables: expect.objectContaining({
"--color-background-primary": "#101113",
}),
await waitFor(() =>
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
expect.objectContaining({
theme: "dark",
styles: expect.objectContaining({
variables: expect.objectContaining({
"--color-background-primary": "#101113",
}),
}),
),
{ timeout: 5000 },
}),
),
);
} finally {
getComputedStyleSpy.mockRestore();

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import {
act,
RAF_SLACK_MS,
renderWithMantine,
renderWithMantineTransitions,
screen,
@ -18,7 +19,18 @@ import { HEADER_ANIM_MS, ViewHeader } from "./ViewHeader";
// duration plus rAF scheduling slack, so bumping HEADER_ANIM_MS can't silently
// make the settle insufficient. (This is why ViewHeader.tsx exports
// HEADER_ANIM_MS — the settle window must track the animation it settles.)
const TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200;
//
// The slack half is `RAF_SLACK_MS` rather than a bare `+ 200` (#2323): the two
// terms answer to different things — the first to the component, the second to
// how busy the machine is — and only the second should move when the machine
// gets busier. Naming it is what lets it move in one place, here and in the
// helper's own fallback.
//
// No `enterDelay`/`exitDelay` is passed to any of ViewHeader's four
// `Transition`s, so HEADER_ANIM_MS really is the longest JS chain. The 150ms
// stagger visible in the UI is a CSS `animation-delay` (`App.css`), and a CSS
// animation schedules no JS timer for the settle to drain.
const TRANSITION_SETTLE_MS = HEADER_ANIM_MS + RAF_SLACK_MS;
// Mock @mantine/hooks so we can control useMediaQuery results per test.
const mediaQueryMock = vi.hoisted(() => ({ value: false }));

View file

@ -7,6 +7,14 @@
* is one process-wide queue per path, so two in-process callers are ordered
* before the lock ever sees them. A child process is not scaffolding here —
* it is the only participant that can produce the interleaving.
*
* Four tests here raise their own budget past the integration project's 30s,
* and are among the handful in the repo that still should (#2323). What they
* wait on is not React or a renderer — it is `proper-lockfile`'s stale-lock
* timeout and a second OS process actually contending for the file, which is
* real elapsed work rather than slack. The named constants below say so at
* each site; the restatements of the project's own 30s that used to sit
* alongside them are gone.
*/
import {
describe,
@ -28,6 +36,11 @@ import {
withSecretFileLock,
resetFileLockWarnings,
} from "@inspector/core/auth/node/file-lock.js";
/** A test that waits out a full stale-lock takeover in a second process. */
const LOCK_TAKEOVER_MS = 90_000;
/** A test that waits on cross-process contention but not on a stale window. */
const LOCK_CONTENTION_MS = 60_000;
import { FileSecretStore } from "@inspector/core/auth/node/file-secret-store.js";
import { SecretStoreUnavailableError } from "@inspector/core/auth/node/secret-store.js";
@ -150,7 +163,7 @@ describe("withSecretFileLock across processes", () => {
// …and having waited, it did not report a degraded write.
expect(warnings()).toBe("");
await done;
}, 20_000);
});
it("locks a file that does not exist yet", async () => {
// The first `set` on a fresh install has no `secrets.json` — and
@ -204,7 +217,7 @@ describe("withSecretFileLock across processes", () => {
expect(await reader.get("srv", "env:FROM_CHILD")).toBe("1");
expect(await reader.get("srv", "env:FROM_PARENT")).toBe("2");
expect(warnings()).toBe("");
}, 20_000);
});
it("creates the storage directory so the very first save is locked too", async () => {
// `writeStoreFile` creates the parent directory, but from *inside* the
@ -232,7 +245,7 @@ describe("withSecretFileLock across processes", () => {
expect(await reader.get("srv", "env:FIRST_EVER")).toBe("1");
// No degrade warning: the lock was genuinely held, not skipped.
expect(warnings()).toBe("");
}, 20_000);
});
});
describe("withSecretFileLock degrades rather than failing", () => {
@ -263,131 +276,147 @@ describe("withSecretFileLock degrades rather than failing", () => {
expect(warn).toHaveBeenCalledTimes(1);
});
it("refuses the save rather than writing alongside a live holder", async () => {
// `ELOCKED` is evidence the lock is *working*, so degrading here would
// enter the exact interleaving the lock exists to prevent — and enter it
// knowing another writer is there. Held from this process, which is
// indistinguishable to `proper-lockfile` from a remote holder (it is not
// reentrant); the in-process queue is what keeps that out of the way in
// production.
//
// The wait is real: the retry budget deliberately outlasts the 10s stale
// window so a *crashed* holder resolves by takeover instead of failing
// everyone else's saves. This holder is alive and refreshing, so it never
// goes stale and the budget is spent in full.
const target = filePath();
const lockfile = require_(LOCKFILE_MODULE) as {
lock: (f: string, o: object) => Promise<() => Promise<void>>;
};
// The **same** `stale` production uses, and that is not incidental:
// `isLockStale` is evaluated against the *waiter's* threshold while the
// holder refreshes on its own `stale / 2`. A holder configured looser
// (say 60s) refreshes every 30s and is therefore declared stale by a
// 10s waiter after 10s — the waiter takes over and the save succeeds,
// quietly testing the opposite of what this test claims.
const release = await lockfile.lock(target, {
realpath: false,
stale: 10_000,
});
it(
"refuses the save rather than writing alongside a live holder",
async () => {
// `ELOCKED` is evidence the lock is *working*, so degrading here would
// enter the exact interleaving the lock exists to prevent — and enter it
// knowing another writer is there. Held from this process, which is
// indistinguishable to `proper-lockfile` from a remote holder (it is not
// reentrant); the in-process queue is what keeps that out of the way in
// production.
//
// The wait is real: the retry budget deliberately outlasts the 10s stale
// window so a *crashed* holder resolves by takeover instead of failing
// everyone else's saves. This holder is alive and refreshing, so it never
// goes stale and the budget is spent in full.
const target = filePath();
const lockfile = require_(LOCKFILE_MODULE) as {
lock: (f: string, o: object) => Promise<() => Promise<void>>;
};
// The **same** `stale` production uses, and that is not incidental:
// `isLockStale` is evaluated against the *waiter's* threshold while the
// holder refreshes on its own `stale / 2`. A holder configured looser
// (say 60s) refreshes every 30s and is therefore declared stale by a
// 10s waiter after 10s — the waiter takes over and the save succeeds,
// quietly testing the opposite of what this test claims.
const release = await lockfile.lock(target, {
realpath: false,
stale: 10_000,
});
const store = new FileSecretStore({ filePath: target });
// One call, both assertions off the same rejection: each attempt spends
// the full retry budget, so a second would double the test's runtime to
// re-prove the same thing.
const err = await store
.set("srv", "env:MINE", "1")
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(SecretStoreUnavailableError);
expect((err as Error).message).toMatch(/was not saved/);
await release();
const store = new FileSecretStore({ filePath: target });
// One call, both assertions off the same rejection: each attempt spends
// the full retry budget, so a second would double the test's runtime to
// re-prove the same thing.
const err = await store
.set("srv", "env:MINE", "1")
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(SecretStoreUnavailableError);
expect((err as Error).message).toMatch(/was not saved/);
await release();
// Nothing was written behind the holder's back.
expect(existsSync(target)).toBe(false);
}, 90_000);
// Nothing was written behind the holder's back.
expect(existsSync(target)).toBe(false);
},
LOCK_TAKEOVER_MS,
);
it("refuses rather than degrading when a stale lock cannot be cleared", async () => {
// `acquireLock` does not only *create* directories — on finding a stale
// one it removes it and retries, and that removal can fail. A stale lock
// with anything inside it fails `ENOTEMPTY`, which is not `ELOCKED`, and
// treating every non-`ELOCKED` error as "locks do not work here" meant
// every Inspector on the box quietly bypassed the *same* stuck lock and
// raced its writes — while the release-failure message was telling the
// operator saves would keep failing until they cleared it.
const target = filePath();
const lockPath = `${target}.lock`;
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.mkdir(lockPath);
await fs.writeFile(`${lockPath}/stray`, "", "utf-8");
// Backdated so it reads as stale — which is what sends `acquireLock` down
// the remove-and-retry path rather than straight to `ELOCKED`.
const longDead = new Date(Date.now() - 60_000);
await fs.utimes(lockPath, longDead, longDead);
it(
"refuses rather than degrading when a stale lock cannot be cleared",
async () => {
// `acquireLock` does not only *create* directories — on finding a stale
// one it removes it and retries, and that removal can fail. A stale lock
// with anything inside it fails `ENOTEMPTY`, which is not `ELOCKED`, and
// treating every non-`ELOCKED` error as "locks do not work here" meant
// every Inspector on the box quietly bypassed the *same* stuck lock and
// raced its writes — while the release-failure message was telling the
// operator saves would keep failing until they cleared it.
const target = filePath();
const lockPath = `${target}.lock`;
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.mkdir(lockPath);
await fs.writeFile(`${lockPath}/stray`, "", "utf-8");
// Backdated so it reads as stale — which is what sends `acquireLock` down
// the remove-and-retry path rather than straight to `ELOCKED`.
const longDead = new Date(Date.now() - 60_000);
await fs.utimes(lockPath, longDead, longDead);
const store = new FileSecretStore({ filePath: target });
const err = await store
.set("srv", "env:MINE", "1")
.catch((e: unknown) => e);
const store = new FileSecretStore({ filePath: target });
const err = await store
.set("srv", "env:MINE", "1")
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(SecretStoreUnavailableError);
expect((err as Error).message).toMatch(/was not saved/);
// Nothing written behind the stuck lock, and no "unprotected" warning:
// this is a refusal, not a degrade.
expect(existsSync(target)).toBe(false);
expect(warnings()).not.toContain("not protected");
}, 90_000);
expect(err).toBeInstanceOf(SecretStoreUnavailableError);
expect((err as Error).message).toMatch(/was not saved/);
// Nothing written behind the stuck lock, and no "unprotected" warning:
// this is a refusal, not a degrade.
expect(existsSync(target)).toBe(false);
expect(warnings()).not.toContain("not protected");
},
LOCK_TAKEOVER_MS,
);
it("takes over the lock of a holder that died, rather than failing the save", async () => {
// The invariant behind refusing on `ELOCKED`: refusing is only defensible
// because a *crashed* holder resolves on its own first. `RETRY` therefore
// has to outlast `STALE_MS` — if the budget were the shorter of the two,
// one Inspector killed mid-save would make every later save on the box
// fail until someone deleted the lock by hand.
//
// A dead holder is exactly a lock directory nobody is refreshing, so it
// is staged directly: no child to race, and no dependence on how quickly
// a killed process is reaped.
const target = filePath();
const lockPath = `${target}.lock`;
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.mkdir(lockPath);
const longDead = new Date(Date.now() - 60_000);
await fs.utimes(lockPath, longDead, longDead);
it(
"takes over the lock of a holder that died, rather than failing the save",
async () => {
// The invariant behind refusing on `ELOCKED`: refusing is only defensible
// because a *crashed* holder resolves on its own first. `RETRY` therefore
// has to outlast `STALE_MS` — if the budget were the shorter of the two,
// one Inspector killed mid-save would make every later save on the box
// fail until someone deleted the lock by hand.
//
// A dead holder is exactly a lock directory nobody is refreshing, so it
// is staged directly: no child to race, and no dependence on how quickly
// a killed process is reaped.
const target = filePath();
const lockPath = `${target}.lock`;
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.mkdir(lockPath);
const longDead = new Date(Date.now() - 60_000);
await fs.utimes(lockPath, longDead, longDead);
const store = new FileSecretStore({ filePath: target });
await store.set("srv", "env:AFTER_CRASH", "1");
const store = new FileSecretStore({ filePath: target });
await store.set("srv", "env:AFTER_CRASH", "1");
const reader = new FileSecretStore({ filePath: target });
expect(await reader.get("srv", "env:AFTER_CRASH")).toBe("1");
// Took the lock over — did not fall through to an unlocked write.
expect(warnings()).toBe("");
}, 60_000);
const reader = new FileSecretStore({ filePath: target });
expect(await reader.get("srv", "env:AFTER_CRASH")).toBe("1");
// Took the lock over — did not fall through to an unlocked write.
expect(warnings()).toBe("");
},
LOCK_CONTENTION_MS,
);
it("stays silent per the delete contract when the lock is held", async () => {
// `delete` reports nothing by contract — only `set` hard-fails — so the
// refusal above must not turn a delete into a throw.
const target = filePath();
const store = new FileSecretStore({ filePath: target });
await store.set("srv", "env:A", "1");
it(
"stays silent per the delete contract when the lock is held",
async () => {
// `delete` reports nothing by contract — only `set` hard-fails — so the
// refusal above must not turn a delete into a throw.
const target = filePath();
const store = new FileSecretStore({ filePath: target });
await store.set("srv", "env:A", "1");
const lockfile = require_(LOCKFILE_MODULE) as {
lock: (f: string, o: object) => Promise<() => Promise<void>>;
};
// The **same** `stale` production uses, and that is not incidental:
// `isLockStale` is evaluated against the *waiter's* threshold while the
// holder refreshes on its own `stale / 2`. A holder configured looser
// (say 60s) refreshes every 30s and is therefore declared stale by a
// 10s waiter after 10s — the waiter takes over and the save succeeds,
// quietly testing the opposite of what this test claims.
const release = await lockfile.lock(target, {
realpath: false,
stale: 10_000,
});
await expect(store.delete("srv", "env:A")).resolves.toBeUndefined();
await release();
const lockfile = require_(LOCKFILE_MODULE) as {
lock: (f: string, o: object) => Promise<() => Promise<void>>;
};
// The **same** `stale` production uses, and that is not incidental:
// `isLockStale` is evaluated against the *waiter's* threshold while the
// holder refreshes on its own `stale / 2`. A holder configured looser
// (say 60s) refreshes every 30s and is therefore declared stale by a
// 10s waiter after 10s — the waiter takes over and the save succeeds,
// quietly testing the opposite of what this test claims.
const release = await lockfile.lock(target, {
realpath: false,
stale: 10_000,
});
await expect(store.delete("srv", "env:A")).resolves.toBeUndefined();
await release();
// …and the entry it could not delete is still there, not half-removed.
expect(await store.get("srv", "env:A")).toBe("1");
}, 90_000);
// …and the entry it could not delete is still there, not half-removed.
expect(await store.get("srv", "env:A")).toBe("1");
},
LOCK_TAKEOVER_MS,
);
});
describe("withSecretFileLock reports what it cannot clean up", () => {
@ -457,7 +486,7 @@ describe("withSecretFileLock reports what it cannot clean up", () => {
// Unguarded, the winner's directory is gone.
expect(existsSync(lockPath)).toBe(false);
}, 30_000);
});
it("tells the operator to clear a lock that cannot expire on its own", async () => {
// `rmdir` refuses a non-empty directory — and so does stale takeover,
@ -510,5 +539,5 @@ describe("withSecretFileLock reports what it cannot clean up", () => {
// that did nothing wrong. `onCompromised` has already said what happened.
expect(warnings()).not.toContain("by hand");
expect(warnings()).not.toContain("Could not release the lock");
}, 30_000);
});
});

View file

@ -715,6 +715,10 @@ describe("absorbFileSecretsIntoKeyring", () => {
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Could not lock the secrets file"),
);
// Past the integration project's own 30s deliberately, and one of the few
// sites that should be (#2323): this test waits on a second process holding
// the secrets-file lock until `proper-lockfile` gives up on it, which is
// real elapsed contention rather than slack for a loaded machine.
}, 60_000);
it("takes no lock when there is nothing to migrate (#2082)", async () => {

View file

@ -96,7 +96,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
metadata = (await (
await fetch(`${serverUrl}/.well-known/oauth-authorization-server`)
).json()) as OAuthMetadata;
}, 30_000);
});
afterAll(async () => {
await mcpServer?.stop();
@ -105,7 +105,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
rmSync(storageDir, { recursive: true, force: true });
storageDir = "";
}
}, 30_000);
});
/** Run a real authorization-code exchange and return the issued tokens. */
async function authorize(

View file

@ -51,12 +51,12 @@ describe("RFC 8414 metadata at the OIDC well-known path (#2172)", () => {
const port = await mcpServer.start();
serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl, { metadataPath: OIDC_PATH });
}, 30_000);
});
afterAll(async () => {
await mcpServer?.stop();
mcpServer = null;
}, 30_000);
});
it("serves plain RFC 8414 metadata only from the OIDC path", async () => {
const oidc = await fetch(`${serverUrl}${OIDC_PATH}`);

View file

@ -70,7 +70,7 @@ describe("OAuth connect on the probing eras (#1805)", () => {
await mcpServer.stop();
mcpServer = null;
}
}, 30_000);
});
afterAll(() => {
try {
@ -166,7 +166,7 @@ describe("OAuth connect on the probing eras (#1805)", () => {
expect(
(error as AuthRecoveryRequiredError).authorizationUrl,
).toBeInstanceOf(URL);
}, 30_000);
});
it(`connects on the "${era}" era once authorization completes`, async () => {
const serverUrl = await startProtectedModernServer();
@ -183,6 +183,6 @@ describe("OAuth connect on the probing eras (#1805)", () => {
expect(authorizing.getProtocolEra()).toBe("modern");
expect(authorizing.getProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION);
expect((await authorizing.listTools()).tools.length).toBeGreaterThan(0);
}, 30_000);
});
}
});

View file

@ -493,7 +493,7 @@ describe("modern-era negotiation (2026-07-28)", () => {
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
await expect(aborted).resolves.toBe(true);
}, 30_000);
});
// The test above builds its own never-returning tool, which is the sharpest
// way to assert on the server's abort signal but says nothing about the
@ -576,7 +576,7 @@ describe("modern-era negotiation (2026-07-28)", () => {
// The handler must return promptly, saying it was cancelled — not run on to
// its 30th second, which is what it did before the fix.
await expect(outcome).resolves.toContain("cancelled after");
}, 30_000);
});
it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
const started = await startMrtrServer(createMrtrTool());

View file

@ -54,7 +54,7 @@ describe("InspectorClient direct mid-session OAuth", () => {
await mcpServer.stop();
mcpServer = null;
}
}, 30_000);
});
afterAll(() => {
try {
@ -139,7 +139,7 @@ describe("InspectorClient direct mid-session OAuth", () => {
expect(client.getStatus()).toBe("connected");
await client.disconnect();
}, 30_000);
});
it("step-up after insufficient_scope throws AuthRecoveryRequiredError then succeeds after OAuth", async () => {
const staticClientId = "test-direct-step-up";
@ -244,5 +244,5 @@ describe("InspectorClient direct mid-session OAuth", () => {
expect(result.success).toBe(true);
await client.disconnect();
}, 30_000);
});
});

Some files were not shown because too many files have changed in this diff Show more