inspector/test-servers/src/test-helpers.ts
Cliff Hall e1c8f71924
fix(storage): await store-file writes instead of polling (#1412) (#1413)
* fix(storage): await store-file writes instead of polling (#1412)

OAuth persistence is Zustand `persist` → file-storage adapter →
writeStoreFile, which the persist middleware invokes fire-and-forget:
saveTokens updates the in-memory store synchronously while the file
write lags. Tests verified persistence by polling the state file with a
2s/50ms `waitForStateFile()` budget, which flaked under full-suite load
(the file was written, the poll just didn't observe it in time).

Make the write completion awaitable instead:
- Track in-flight writeStoreFile() promises per resolved path in
  store-io and add flushStoreFileWrites(filePath?) to await them (also
  useful for graceful shutdown). Writes to the same path are chained so a
  flush awaits every queued write.
- Convert the OAuth E2E, node storage, and file-storage-adapter tests
  from poll loops (waitForStateFile / vi.waitFor on existsSync /
  setTimeout) to `await flushStoreFileWrites(path)` + a single read.
- Remove the now-unused waitForStateFile() test helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(storage): doc load-bearing sync registration; rename stale debug env

Address @claude review on #1413:
- Document that writeStoreFile()'s synchronous pendingWrites registration is
  load-bearing for flushStoreFileWrites(), in both the storage adapter's
  setItem callsite and the pendingWrites docblock, so future refactors don't
  introduce an await before registration and silently re-flake the tests.
- Rename the stale DEBUG_WAIT_FOR_STATE_FILE test env var to DEBUG_PERSIST_PATH
  (waitForStateFile was removed in this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:55:24 -04:00

193 lines
6.1 KiB
TypeScript

/**
* Test helpers for event-driven waits and polling.
* Use these instead of arbitrary setTimeout/setInterval in E2E tests.
*/
import { vi } from "vitest";
export interface WaitForEventOptions {
timeout?: number;
}
/**
* Wait for a single event on an EventTarget. Resolves with the event detail,
* or rejects after `timeout` ms if the event never fires.
*/
export function waitForEvent<T = unknown>(
target: EventTarget,
eventName: string,
options?: WaitForEventOptions,
): Promise<T> {
const timeoutMs = options?.timeout ?? 5000;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
target.removeEventListener(eventName, handler);
reject(
new Error(`Timeout waiting for event '${eventName}' (${timeoutMs}ms)`),
);
}, timeoutMs);
const handler = (e: Event) => {
clearTimeout(timer);
target.removeEventListener(eventName, handler);
resolve((e as CustomEvent<T>).detail);
};
target.addEventListener(eventName, handler);
});
}
export interface WaitForProgressCountOptions {
timeout?: number;
}
/**
* Wait until `progressNotification` has been received `expectedCount` times.
* Returns the collected event details. Use for sendProgress and progress-linked-to-tasks tests.
*/
export function waitForProgressCount(
client: {
addEventListener: (type: string, fn: (e: Event) => void) => void;
removeEventListener: (type: string, fn: (e: Event) => void) => void;
},
expectedCount: number,
options?: WaitForProgressCountOptions,
): Promise<unknown[]> {
const timeoutMs = options?.timeout ?? 5000;
const events: unknown[] = [];
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
client.removeEventListener("progressNotification", handler);
reject(
new Error(
`Timeout waiting for ${expectedCount} progressNotification events (got ${events.length}) after ${timeoutMs}ms`,
),
);
}, timeoutMs);
const handler = (e: Event) => {
events.push((e as CustomEvent).detail);
if (events.length >= expectedCount) {
clearTimeout(timer);
client.removeEventListener("progressNotification", handler);
resolve(events);
}
};
client.addEventListener("progressNotification", handler);
});
}
export interface WaitForOAuthWellKnownOptions {
timeout?: number;
interval?: number;
/** Max time per fetch attempt (so one hung request doesn't burn the whole timeout). Default 1000. */
requestTimeout?: number;
}
/**
* Poll the OAuth authorization server well-known URL until it returns 200.
* Use after server.start() and before client.authenticate() in E2E tests so
* the SDK's discovery never races with server readiness (which would cause
* it to fall back to /authorize instead of /oauth/authorize).
*
* @param serverBaseUrl - Base URL of the server (e.g. http://localhost:PORT)
*/
export async function waitForOAuthWellKnown(
serverBaseUrl: string,
options?: WaitForOAuthWellKnownOptions,
): Promise<void> {
const {
timeout = 5000,
interval = 50,
requestTimeout = 1000,
} = options ?? {};
const wellKnownUrl = `${serverBaseUrl.replace(/\/$/, "")}/.well-known/oauth-authorization-server`;
const start = Date.now();
let lastStatus: number | undefined;
let lastError: unknown;
while (Date.now() - start < timeout) {
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), requestTimeout);
try {
const res = await fetch(wellKnownUrl, { signal: controller.signal });
lastStatus = res.status;
if (res.ok) return;
} finally {
clearTimeout(t);
}
} catch (err) {
lastError = err;
// connection error or request timeout, retry
}
await new Promise((r) => setTimeout(r, interval));
}
const statusPart =
lastStatus !== undefined
? `lastStatus: ${lastStatus}`
: "lastStatus: (none)";
const errorPart =
lastError !== undefined
? `lastError: ${lastError instanceof Error ? lastError.message : String(lastError)}`
: "lastError: (none)";
throw new Error(
`waitForOAuthWellKnown timed out after ${timeout}ms: ${wellKnownUrl}. ${statusPart}, ${errorPart}`,
);
}
export interface WaitForRemoteStoreOptions {
timeout?: number;
interval?: number;
/** Max time per fetch attempt. Default 1000. */
requestTimeout?: number;
}
/**
* Poll GET /api/storage/:storeId until the response body satisfies `predicate`.
* Use after persisting state (e.g. setServerState or client disconnect) and before
* creating a second client/store or asserting on the API, so the test doesn't race
* with async persist (Zustand setItem).
*
* Uses x-mcp-remote-auth: Bearer <authToken> for the request.
*
* @param baseUrl - Remote server base URL (e.g. http://127.0.0.1:PORT)
* @param storeId - Store ID (e.g. "oauth", "test-store")
* @param authToken - Auth token for x-mcp-remote-auth header
* @param predicate - Called with parsed JSON body; return true when ready
*/
export async function waitForRemoteStore(
baseUrl: string,
storeId: string,
authToken: string,
predicate: (body: unknown) => boolean,
options?: WaitForRemoteStoreOptions,
): Promise<void> {
const {
timeout = 3000,
interval = 50,
requestTimeout = 1000,
} = options ?? {};
const url = `${baseUrl.replace(/\/$/, "")}/api/storage/${encodeURIComponent(storeId)}`;
const headers: Record<string, string> = {
"x-mcp-remote-auth": `Bearer ${authToken}`,
};
await vi.waitFor(
async () => {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), requestTimeout);
try {
const res = await fetch(url, { headers, signal: controller.signal });
if (!res.ok) {
throw new Error(
`waitForRemoteStore: GET ${url} returned ${res.status}`,
);
}
const body: unknown = await res.json();
if (!predicate(body)) {
throw new Error("waitForRemoteStore: predicate not yet satisfied");
}
} finally {
clearTimeout(t);
}
},
{ timeout, interval },
);
}