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
16 changed files with 1276 additions and 394 deletions

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

View file

@ -104,7 +104,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
}
remoteBaseUrl = undefined;
remoteAuthToken = undefined;
}, 30_000);
});
afterAll(() => {
try {
@ -206,7 +206,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
expect(client.getStatus()).toBe("connected");
await client.disconnect();
}, 15_000);
});
it("recovers on reconnect when stored access token was invalidated before connect", async () => {
await setupRemoteServer();
@ -446,7 +446,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
await callbackClient.disconnect();
await client.disconnect();
}, 30_000);
});
it("resumeAfterOAuth falls back to connect when remote session is dead", async () => {
await setupRemoteServer();
@ -549,7 +549,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
await callbackClient.disconnect();
await client.disconnect();
}, 30_000);
});
it("dispatches authChallengeInteractive for ambient interactive recovery", async () => {
await setupRemoteServer();
@ -640,7 +640,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
).toBeInstanceOf(URL);
await client.disconnect();
}, 15_000);
});
it("recovers from ambient auth notification after idle token invalidation", async () => {
await setupRemoteServer();
@ -731,5 +731,5 @@ describe("InspectorClient remote mid-session OAuth", () => {
expect(client.getStatus()).toBe("connected");
await client.disconnect();
}, 15_000);
});
});

View file

@ -74,7 +74,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => {
serverUrl = `http://localhost:${port}`;
storageDir = mkdtempSync(join(tmpdir(), "mcp-inspector-2071-"));
await waitForOAuthWellKnown(serverUrl);
}, 30_000);
});
afterAll(async () => {
await mcpServer?.stop();
@ -83,7 +83,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => {
rmSync(storageDir, { recursive: true, force: true });
storageDir = "";
}
}, 30_000);
});
it("advertises the non-default metadata URL on the 401 challenge", async () => {
const response = await fetch(`${serverUrl}/mcp`, { method: "POST" });
@ -179,7 +179,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => {
).toEqual([]);
await client.disconnect();
}, 30_000);
});
it("observes the SSE challenge through a caller-supplied eventSourceInit.fetch", async () => {
// That explicit fetch is deliberately not proxy- or intercept-wrapped, so
@ -225,7 +225,7 @@ describe("OAuth challenge resource_metadata (RFC 9728)", () => {
expect(observed).toContainEqual(
expect.objectContaining({ resourceMetadataUrl: METADATA_URL_LITERAL }),
);
}, 30_000);
});
it("keeps the showcase config valid and carrying the custom path", () => {
const configPath = fileURLToPath(

View file

@ -174,7 +174,7 @@ describe("web-client cancellation over a per-request stream (#2140)", () => {
// `notifications/cancelled` it was free to ignore (and did), its request
// signal never fired, and this promise never resolved.
await expect(slow.aborted).resolves.toBe(true);
}, 30_000);
});
it("advertises the per-request stream only for streamable-http", () => {
// The flag is what the SDK forks on, and the factory is where a server's

View file

@ -108,12 +108,46 @@ export function renderWithMantineTransitions(
return { ...result, unmount: downgradingUnmount };
}
// Fallback settle window: ≈500ms clears a typical few-hundred-millisecond
// transition plus the two-frame rAF scheduling slack. Callers that know their
// component's animation duration should pass `settleMs` (its longest JS timer
// chain — duration plus any `enterDelay`/`exitDelay` — plus slack) rather than
// rely on this default.
const DEFAULT_SETTLE_MS = 500;
// A settle window is two terms with completely different relationships to the
// machine, and they must not be scaled as one number (#2323).
//
// The first term tracks the component: the longest JS timer chain its
// transition schedules — its duration plus any `enterDelay`/`exitDelay`. That
// is fixed by the component and load does not move it. Callers that know their
// component derive it from the component's own exported constant, which is why
// `ViewHeader.test.tsx` writes `HEADER_ANIM_MS + RAF_SLACK_MS` rather than a
// literal: bumping the animation then cannot silently outrun the settle.
//
// The second term is this one — the slack in which the rAF callbacks, the React
// commit and the terminal `setTimeout` they schedule actually have to *run*.
// That work is CPU-bound, so it is the term an oversubscribed machine eats, and
// the only term that should grow when the machine gets busier.
//
// ⚠️ Unlike a timeout, this is a fixed sleep: it always waits the full window
// and never exits early. Raising it therefore costs every *passing* run (3 call
// sites x 300ms ≈ 0.9s), which is the opposite call from the one made for the
// repo's ordinary fixed sleeps. It is justified here only because this failure
// is silent and displaced rather than loud and local: an insufficient settle
// fails nothing at its own site — the test's assertions have already passed —
// and surfaces either as a leaked timer that `setup.ts` cancels with no report
// at all, or as an uncaught post-teardown `window is not defined` failing the
// whole run from an arbitrary innocent file (#1760). Re-running localizes
// neither. Do not generalize this to the ordinary sleeps.
//
// (A condition wait is not available and should not be re-litigated: a
// completed *enter* transition leaves no DOM signal to `waitFor` on, which is
// the whole reason this is a sleep at all.)
export const RAF_SLACK_MS = 500;
// The animation term of the fallback window, for a caller that does not know
// its component's duration. A typical Mantine transition is a few hundred ms.
const TYPICAL_ANIM_MS = 300;
// Fallback settle window, used by `settleTransitions()` called with no
// argument. Built from the same slack constant as the derived call sites so the
// two cannot drift apart. Callers that know their component's animation
// duration should pass `settleMs` instead of relying on this.
const DEFAULT_SETTLE_MS = TYPICAL_ANIM_MS + RAF_SLACK_MS;
// Set by `renderWithMantineTransitions`; consumed once by the `afterEach` below.
// `armedSettleMs === null` means no real-transitions render happened this test,
@ -165,8 +199,12 @@ function resetArming() {
// `setTimeout`, and the React work they schedule against the still-mounted tree
// — whichever is the actual escapee, it resolves on a live component before
// cleanup unmounts. Because it awaits a *real* `setTimeout`, it deadlocks under
// fake timers, so guard against that with a clear message rather than a 5s
// test-timeout hang.
// fake timers, so guard against that with a clear message rather than leaving
// it to the project's `hookTimeout` — which is what actually bounds this await,
// since the auto-settle runs in an `afterEach`. That is 30000ms
// (`vitest.shared.mts`, #2323), so without this throw a fake-timer deadlock
// would take half a minute to report something naming neither transitions nor
// fake timers.
export async function settleTransitions(ms: number = DEFAULT_SETTLE_MS) {
if (vi.isFakeTimers()) {
throw new Error(

View file

@ -1,6 +1,25 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import { cleanup, configure } from "@testing-library/react";
// Testing Library's own `asyncUtilTimeout` default is 1000ms
// (`@testing-library/dom/dist/config.js`), and it governs every `waitFor` and
// `findBy*` in the unit project — 788 call sites — none of which chose it. One
// `configure` call is the whole surface, which is why it is here rather than
// argued a site at a time (#2323).
//
// 5x, because a contended happy-dom render is the worst-measured case on this
// team's machine (8 logical cores, three or four concurrent worktree sessions
// each free to run the full `local:gate`), and because once the project's
// `testTimeout` rises to 15000 this becomes the *binding* constraint on every
// async assertion in the project — the enclosing budget stops being the thing
// that cuts a wait short and this starts being it.
//
// The trade-off, stated rather than hidden: a genuinely-failing async
// assertion now takes 5s to report instead of 1s. Only failing assertions pay
// it, once each, on runs that are already the slow path. A *passing* `waitFor`
// returns the instant its callback stops throwing and is unaffected.
configure({ asyncUtilTimeout: 5000 });
// Node 22+ exposes an experimental `localStorage` placeholder that overrides
// happy-dom's implementation. Without `--localstorage-file`, it's an empty

View file

@ -0,0 +1,30 @@
/**
* Setup file for the `storybook` Vitest project (#2323).
*
* It exists for one call. Storybook's instrumented Testing Library re-exports
* the same `asyncUtilTimeout: 1000` default as the unit project's, and it
* governs the 80 `waitFor` / `findBy*` sites across the story files. Those play
* functions run in a real Chromium against a real Storybook preview, so 1000ms
* is the *tighter* of the two bounds the project has — the per-test ceiling is
* 15000 (#2292) — and it is the one nobody chose.
*
* ⚠️ Two things about this file that are deliberate and easy to undo:
*
* 1. **It must not live in `.storybook/`, and must not call
* `setProjectAnnotations`.** `@storybook/addon-vitest` provisions the
* preview annotations itself, and skips doing so when it finds a setup file
* that is *both* inside `configDir` and contains that call (#1898) — so the
* old `.storybook/vitest.setup.ts` was opting out of the automatic path.
* Either half of the condition is enough to stay on it; this file misses
* both. `src/test/PreviewAnnotations.stories.tsx` asserts the provisioning
* directly, so a regression here fails a test rather than silently rendering
* every story without the Mantine decorator.
* 2. **The import is `storybook/test`, not `@testing-library/*`.** Storybook
* instruments its own copy so the interactions panel can trace each step;
* configuring a different copy would change nothing that a play function
* actually calls.
*/
import { configure } from "storybook/test";
configure({ asyncUtilTimeout: 5000 });