inspector/test-servers/src/test-helpers.ts

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 before asserting on the API.
*
* 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 },
);
}