diff --git a/src/index.ts b/src/index.ts index df1f62b..d4a2c04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,3 +14,5 @@ export { JsonUtils } from "./jsonUtils/jsonUtils"; export { StringUtils } from "./stringUtils/stringUtils"; export { Utils, ExistingFileWriteActions } from "./utils/utils"; export type { AssertTypeMap, ActionAndParams } from "./utils/utils"; + +export { Mock } from "./mock/mock"; diff --git a/src/logger/logger.ts b/src/logger/logger.ts index 57d057f..4407a74 100644 --- a/src/logger/logger.ts +++ b/src/logger/logger.ts @@ -656,7 +656,7 @@ export class Logger { const stackObj: unknown = {}; Error.captureStackTrace(stackObj as object, this.writeLine); const stack = (stackObj as Error)?.stack ?? "[Unknown]"; - const callingMethodDetails = this.callingMethodDetails(stack); + const callingMethodDetails = this.callingMethodDetails(stack, options?.stackOffset ?? 0); const maxLines = options?.maxLines ?? (this.options.writeLine.maxLines as number); @@ -950,7 +950,7 @@ export class Logger { } } - private static callingMethodDetails(methodBase: string): string { + private static callingMethodDetails(methodBase: string, stackOffset = 0): string { let methodName = ""; let typeName = ""; if (methodBase) { @@ -962,7 +962,9 @@ export class Logger { .findIndex((item) => !item.includes(LOGGER_STACK_FRAME_MARKER)); indexOfFirstNonLogLine = indexOfFirstNonLogLine === -1 ? 1 : indexOfFirstNonLogLine + 1; - methodName = methodBaseLines[indexOfFirstNonLogLine] + const safeOffset = Math.max(0, stackOffset); + const targetIndex = Math.min(indexOfFirstNonLogLine + safeOffset, methodBaseLines.length - 1); + methodName = methodBaseLines[targetIndex] .replace(/\s\s+/g, " ") .trim(); if (methodName.startsWith("at ")) { diff --git a/src/logger/types.ts b/src/logger/types.ts index 2aa683f..56e98c7 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -144,7 +144,9 @@ export interface WriteLineOptions { * // "MyTest.someStep (myTest.ts:45:3)" * Logger.writeLine(Logger.Levels.TestInformation, message, { stackOffset: 1 }); * - * @remarks Not yet implemented — reserved for a future release. + * Values less than 1 are treated as 0 (no offset). Values that would exceed + * the available stack depth are clipped to the outermost frame. + * */ stackOffset?: number; } diff --git a/src/mock/mock.ts b/src/mock/mock.ts new file mode 100644 index 0000000..26cf764 --- /dev/null +++ b/src/mock/mock.ts @@ -0,0 +1,647 @@ +import { randomUUID } from 'node:crypto'; +import { JSONPath } from 'jsonpath-plus'; +import { Utils } from '../utils/utils'; +import { Log, LogLevels } from '..'; + +/** + * Static HTTP request interception and mocking utility for test suites. + * + * `Mock` is designed to sit between a test framework's network interceptor + * (e.g. Playwright's `page.route`) and the application under test. Every + * outgoing request from the AUT is forwarded to {@link Mock.intercept}, which + * decides how to handle it based on the registered listeners. + * + * **Default-deny**: any request that does not match a listener is blocked and + * recorded as an `unmatched` transaction. The test suite has full control — + * nothing reaches the network unless explicitly permitted. + * + * **All transactions are stored** regardless of outcome, so test steps can + * later inspect real and mocked traffic via {@link Mock.getTransactions}. + * + * All methods are static — no instantiation is required. Call {@link Mock.reset} + * in `beforeEach` / `afterEach` hooks to start each test with a clean slate. + * + * --- + * + * ### Local mode (default) + * + * `Mock` runs in the same process as the test framework. When a `passthrough` + * listener matches, `Mock` fetches the real response itself and returns it + * directly. {@link Mock.intercept} always resolves to a {@link Mock.InterceptResult} + * with `action: 'fulfill'` or `action: 'block'`. + * + * ### Delegate mode + * + * Enable with `Mock.delegateMode = true` when `Mock` is hosted in a remote + * server (e.g. a mock proxy) and the caller — not `Mock` — is better placed + * to perform the real network fetch. In this mode, when a `passthrough` + * listener matches, {@link Mock.intercept} returns `action: 'passthrough'` + * plus a `correlationId`. The caller fetches the real response and reports it + * back via {@link Mock.completePassthrough}, allowing `Mock` to record the + * full transaction. Use {@link Mock.getPendingTransactions} to detect + * passthrough calls that were never completed. + * + * @example + * ```typescript + * // Local mode — Playwright adapter + * Mock.reset(); + * Mock.addListener('get-users', + * ['$[?(@.url =~ /api\\/users/)]', '$[?(@.method == "GET")]'], + * { status: 200, body: [{ id: 1, name: 'Alice' }] } + * ); + * + * await page.route('**\/*', async (route) => { + * const result = await Mock.intercept({ + * url: route.request().url(), + * method: route.request().method(), + * headers: await route.request().allHeaders(), + * }); + * if (result.action === 'fulfill') { + * await route.fulfill({ status: result.response.status }); + * } else { + * await route.abort(); + * } + * }); + * ``` + * + * @example + * ```typescript + * // Delegate mode — remote mock server + * Mock.delegateMode = true; + * + * // In the interceptor callback: + * const result = await Mock.intercept(request); + * if (result.action === 'passthrough') { + * const real = await myFetch(request); + * Mock.completePassthrough(result.correlationId, real); + * return real; + * } + * ``` + */ +export class Mock { + private static listeners = new Map(); + private static transactions: Mock.Transaction[] = []; + private static transactionCounter = 0; + private static pendingTransactions = new Map(); + private static _delegateMode = false; + + // ── Configuration ────────────────────────────────────────────────────────── + + /** + * When `true`, passthrough requests are delegated to the caller rather than + * fetched by `Mock` itself. {@link Mock.intercept} returns + * `action: 'passthrough'` with a `correlationId` for the caller to use when + * reporting the real response back via {@link Mock.completePassthrough}. + * + * Defaults to `false` (local mode — `Mock` fetches passthroughs itself). + * + * @example + * Mock.delegateMode = true; + */ + static set delegateMode(value: boolean) { + Utils.assertType(value, 'boolean', 'delegateMode', 'value'); + Log.writeLine(LogLevels.FrameworkInformation, `Mock: delegate mode ${value ? 'enabled' : 'disabled'}`); + Mock._delegateMode = value; + } + + static get delegateMode(): boolean { + return Mock._delegateMode; + } + + // ── Listener management ──────────────────────────────────────────────────── + + /** + * Registers a named listener that matches intercepted requests and defines + * how `Mock` should respond to them. + * + * Listeners are evaluated in registration order. The first listener whose + * every matcher returns a result wins. If a listener with the same `name` + * already exists it is replaced and a warning is logged. + * + * @param name - Unique name for this listener. Used as the key in the + * listener store and appears in transaction records and log output. + * Must be a non-empty string. + * + * @param matchers - One or more JSONPath expressions evaluated against the + * {@link Mock.Request} object. All expressions must return at least one + * result for the listener to match (AND logic). Each expression is + * validated for syntactic correctness at registration time — an invalid + * expression throws immediately rather than silently failing at intercept + * time. Must be a non-empty array of non-empty strings. + * + * @param action - What to do when this listener matches: + * - `'block'` — abort the request and return `action: 'block'` to the caller. + * - `'passthrough'` — forward the request to the real endpoint (or delegate + * to the caller in {@link Mock.delegateMode}) and return the real response. + * - {@link Mock.Response} — return the supplied response object directly + * without touching the network. `status` must be a valid HTTP status + * code (100–599). + * + * @param delayMs - Optional delay in milliseconds applied before the + * response is returned, whether mocked, real, or blocked. Useful for + * simulating slow networks. Must be a non-negative finite number. + * Defaults to `0` (no delay). + * + * @throws {Error} If any argument fails validation. + * + * @example + * // Block all requests to an analytics endpoint + * Mock.addListener('block-analytics', ['$[?(@.url =~ /analytics/)]'], 'block'); + * + * @example + * // Return a mock response with a 2-second simulated delay + * Mock.addListener('slow-login', + * ['$[?(@.url =~ /api\\/login/)]'], + * { status: 200, headers: { 'content-type': 'application/json' }, body: { token: 'abc' } }, + * 2000 + * ); + */ + static addListener(name: string, matchers: string[], action: Mock.ListenerAction, delayMs = 0): void { + Utils.assertType(name, 'string', 'addListener', 'name'); + if (name.trim().length === 0) Mock.throwError('addListener', '[name] must not be empty'); + + if (!Array.isArray(matchers)) { + Mock.throwError('addListener', `[matchers] must be a string array, got [${typeof matchers}]`); + } + if (matchers.length === 0) { + Mock.throwError('addListener', '[matchers] array must not be empty'); + } + matchers.forEach((matcher, index) => { + if (typeof matcher !== 'string') { + Mock.throwError('addListener', `[matchers[${index}]] must be a string, got [${typeof matcher}]`); + } + if (matcher.trim().length === 0) { + Mock.throwError('addListener', `[matchers[${index}]] must not be empty`); + } + try { + JSONPath({ path: matcher, json: {}, wrap: true }); + } catch (e) { + Mock.throwError('addListener', `[matchers[${index}]] is not valid JSONPath syntax: "${matcher}". ${(e as Error).message}`); + } + }); + + if (action !== 'block' && action !== 'passthrough') { + if (action === null || typeof action !== 'object' || Array.isArray(action)) { + Mock.throwError('addListener', `[action] must be 'block', 'passthrough', or a Mock.Response object, got [${action === null ? 'null' : typeof action}]`); + } + const response = action as Mock.Response; + if (typeof response.status !== 'number' || !Number.isInteger(response.status) || response.status < 100 || response.status > 599) { + Mock.throwError('addListener', `[action.status] must be a valid HTTP status code (100-599), got [${response.status}]`); + } + if (response.headers !== undefined && (typeof response.headers !== 'object' || response.headers === null || Array.isArray(response.headers))) { + Mock.throwError('addListener', `[action.headers] must be a plain object if provided`); + } + } + + Utils.assertType(delayMs, 'number', 'addListener', 'delayMs'); + if (!Number.isFinite(delayMs) || delayMs < 0) { + Mock.throwError('addListener', `[delayMs] must be a non-negative finite number, got [${delayMs}]`); + } + + if (Mock.listeners.has(name)) { + Log.writeLine(LogLevels.Warning, `Mock.addListener: replacing existing listener <${name}>`); + } + + const isBlockOrPassthrough = action === 'block' || action === 'passthrough'; + const actionText = (() => { + if (action === 'block') return 'Block'; + if (action === 'passthrough') return 'Passthrough'; + return JSON.stringify(action, null, 2); + })(); + Log.writeLine(LogLevels.FrameworkInformation, `Add mock listener <${name}${delayMs > 0 ? ` (Delay by ${delayMs}mS)` : ''}>:\nMatcher: ${matchers.join('\nMatcher: ')}\nAction:${isBlockOrPassthrough ? ` ${actionText}` : `\n${actionText}`}`, { suppressMultilinePreamble: true }); + Mock.listeners.set(name, { name, matchers, action, delayMs }); + } + + /** + * Removes a previously registered listener by name. + * + * If no listener with the given name exists, a warning is logged and the + * call is a no-op. + * + * @param name - Name of the listener to remove. Must be a non-empty string. + * @throws {Error} If `name` is not a non-empty string. + * + * @example + * Mock.removeListener('get-users'); + */ + static removeListener(name: string): void { + Utils.assertType(name, 'string', 'removeListener', 'name'); + if (name.trim().length === 0) Mock.throwError('removeListener', '[name] must not be empty'); + if (!Mock.listeners.has(name)) { + Log.writeLine(LogLevels.Warning, `Mock.removeListener: no listener named <${name}> exists — nothing removed`); + return; + } + Log.writeLine(LogLevels.FrameworkInformation, `Mock: Remove listener <${name}>`); + Mock.listeners.delete(name); + } + + /** + * Removes all registered listeners. + * + * Transaction history and pending transactions are unaffected. Use + * {@link Mock.reset} to clear everything in a single call. + */ + static clearListeners(): void { + Log.writeLine(LogLevels.FrameworkInformation, `Mock: Clear all listeners (${Mock.listeners.size} removed)`); + Mock.listeners.clear(); + } + + // ── Intercept ────────────────────────────────────────────────────────────── + + /** + * Processes an intercepted HTTP request and returns a {@link Mock.InterceptResult} + * describing what the caller should do. + * + * Listeners are evaluated in registration order; the first full match wins. + * Every request — matched or not — is recorded in the transaction history. + * If the matching listener has a `delayMs`, the delay is applied before the + * result is returned. + * + * | Situation | `action` returned | Transaction type | + * |---|---|---| + * | Matched → mock response | `'fulfill'` | `'mocked'` | + * | Matched → passthrough, local mode | `'fulfill'` | `'passthrough'` | + * | Matched → passthrough, delegate mode | `'passthrough'` | pending until {@link Mock.completePassthrough} | + * | Matched → block | `'block'` | `'blocked'` | + * | No listener matched | `'block'` | `'unmatched'` | + * + * @param request - The intercepted request. Must be a non-null object with + * non-empty `url` and `method` string properties. + * + * @returns A promise resolving to a {@link Mock.InterceptResult}. + * + * @throws {Error} If `request` fails validation. + * + * @example + * const result = await Mock.intercept(request); + * if (result.action === 'fulfill') { + * respondWith(result.response); + * } else if (result.action === 'passthrough') { + * // delegate mode only + * const real = await fetch(request.url); + * Mock.completePassthrough(result.correlationId, real); + * respondWith(real); + * } else { + * abortRequest(); + * } + */ + static async intercept(request: Mock.Request): Promise { + if (request === null || request === undefined || typeof request !== 'object' || Array.isArray(request)) { + Mock.throwError('intercept', `[request] must be a non-null object, got [${request === null ? 'null' : typeof request}]`); + } + if (typeof request.url !== 'string' || request.url.trim().length === 0) { + Mock.throwError('intercept', `[request.url] must be a non-empty string, got [${typeof request.url}]`); + } + if (typeof request.method !== 'string' || request.method.trim().length === 0) { + Mock.throwError('intercept', `[request.method] must be a non-empty string, got [${typeof request.method}]`); + } + if (request.headers !== undefined && request.headers !== null && (typeof request.headers !== 'object' || Array.isArray(request.headers))) { + Mock.throwError('intercept', `[request.headers] must be a plain object if provided`); + } + + Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: ${request.method} ${request.url}`); + + const listener = Mock.findMatch(request); + + if (!listener) { + Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: no listener matched — blocking`); + Mock.record({ type: 'unmatched', request }); + return { action: 'block' }; + } + + Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: matched listener <${listener.name}>`); + + if (listener.delayMs > 0) { + Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: delaying ${listener.delayMs}ms`); + await Utils.sleep(listener.delayMs); + } + + if (listener.action === 'block') { + Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: blocking request`); + Mock.record({ type: 'blocked', listenerName: listener.name, request }); + return { action: 'block' }; + } + + if (listener.action === 'passthrough') { + if (Mock._delegateMode) { + const correlationId = randomUUID(); + Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: delegating passthrough (correlationId: ${correlationId})`); + Mock.pendingTransactions.set(correlationId, { + correlationId, + timestamp: new Date(), + listenerName: listener.name, + request, + }); + return { action: 'passthrough', correlationId }; + } + Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: passing through to real endpoint`); + const response = await Mock.fetchReal(request); + Mock.record({ type: 'passthrough', listenerName: listener.name, request, response }); + return { action: 'fulfill', response }; + } + + const response = listener.action as Mock.Response; + Log.writeLine(LogLevels.FrameworkDebug, `Mock.intercept: returning mocked response (status ${response.status})`); + Mock.record({ type: 'mocked', listenerName: listener.name, request, response }); + return { action: 'fulfill', response }; + } + + /** + * Completes a delegated passthrough by supplying the real response the caller + * fetched. Records the full transaction and removes the pending entry. + * + * Only relevant when {@link Mock.delegateMode} is `true`. The `correlationId` + * must match one previously returned by {@link Mock.intercept}. + * + * @param correlationId - The UUID returned in the `action: 'passthrough'` + * result from {@link Mock.intercept}. Must be a non-empty string that + * matches a pending transaction. + * + * @param response - The real response the caller received from the endpoint. + * Must be a valid {@link Mock.Response} with a status code of 100–599. + * + * @throws {Error} If `correlationId` is invalid, unknown, or already completed. + * @throws {Error} If `response` fails validation. + * + * @example + * const result = await Mock.intercept(request); + * if (result.action === 'passthrough') { + * const real = await myFetch(request); + * Mock.completePassthrough(result.correlationId, real); + * } + */ + static completePassthrough(correlationId: string, response: Mock.Response): void { + Utils.assertType(correlationId, 'string', 'completePassthrough', 'correlationId'); + if (correlationId.trim().length === 0) { + Mock.throwError('completePassthrough', '[correlationId] must not be empty'); + } + if (!Mock.pendingTransactions.has(correlationId)) { + Mock.throwError('completePassthrough', `[correlationId] "${correlationId}" does not match any pending passthrough — it may be unknown, already completed, or cleared by reset()`); + } + + if (response === null || response === undefined || typeof response !== 'object' || Array.isArray(response)) { + Mock.throwError('completePassthrough', `[response] must be a non-null object, got [${response === null ? 'null' : typeof response}]`); + } + if (typeof response.status !== 'number' || !Number.isInteger(response.status) || response.status < 100 || response.status > 599) { + Mock.throwError('completePassthrough', `[response.status] must be a valid HTTP status code (100-599), got [${response.status}]`); + } + if (response.headers !== undefined && (typeof response.headers !== 'object' || response.headers === null || Array.isArray(response.headers))) { + Mock.throwError('completePassthrough', `[response.headers] must be a plain object if provided`); + } + + const pending = Mock.pendingTransactions.get(correlationId)!; + Log.writeLine(LogLevels.FrameworkDebug, `Mock.completePassthrough: completing passthrough for correlationId ${correlationId} (status ${response.status})`); + Mock.pendingTransactions.delete(correlationId); + Mock.record({ type: 'passthrough', listenerName: pending.listenerName, request: pending.request, response }); + } + + // ── Transaction history ──────────────────────────────────────────────────── + + /** + * Returns all completed transactions in chronological order. + * + * Includes mocked, passthrough (completed), blocked, and unmatched entries. + * Delegated passthroughs awaiting {@link Mock.completePassthrough} appear in + * {@link Mock.getPendingTransactions} instead. + * + * @returns A read-only array of {@link Mock.Transaction} objects. + * + * @example + * const blocked = Mock.getTransactions().filter(t => t.type === 'blocked'); + */ + static getTransactions(): readonly Mock.Transaction[] { + return Mock.transactions; + } + + /** + * Clears all completed transactions and resets the transaction counter. + * + * Pending passthrough transactions and registered listeners are unaffected. + * Use {@link Mock.reset} to clear everything in a single call. + */ + static clearTransactions(): void { + Log.writeLine(LogLevels.FrameworkInformation, `Mock: Clear transaction history (${Mock.transactions.length} removed)`); + Mock.transactions = []; + Mock.transactionCounter = 0; + } + + /** + * Returns all delegated passthrough transactions that have not yet been + * completed via {@link Mock.completePassthrough}. + * + * Use this in test assertions to verify no passthrough calls were abandoned, + * or to diagnose remote fetch failures. + * + * Only populated when {@link Mock.delegateMode} is `true`. + * + * @returns A read-only array of {@link Mock.PendingTransaction} objects. + * + * @example + * // Assert no orphaned passthroughs after a test + * expect(Mock.getPendingTransactions()).toHaveLength(0); + */ + static getPendingTransactions(): readonly Mock.PendingTransaction[] { + return [...Mock.pendingTransactions.values()]; + } + + // ── Reset ────────────────────────────────────────────────────────────────── + + /** + * Clears all listeners, completed transactions, pending transactions, and + * resets delegate mode to `false`. + * + * Call this in `beforeEach` or `afterEach` hooks to guarantee a clean slate + * between tests. + * + * @example + * beforeEach(() => { + * Mock.reset(); + * Mock.addListener('static-assets', [...], 'passthrough'); + * }); + */ + static reset(): void { + Log.writeLine(LogLevels.FrameworkInformation, `Mock: Reset (${Mock.listeners.size} listeners, ${Mock.transactions.length} transactions, ${Mock.pendingTransactions.size} pending cleared)`); + Mock.listeners.clear(); + Mock.transactions = []; + Mock.transactionCounter = 0; + Mock.pendingTransactions.clear(); + Mock._delegateMode = false; + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + private static throwError(funcName: string, message: string): never { + const errorText = `Mock.${funcName}: ${message}`; + Log.writeLine(LogLevels.Error, errorText, { stackOffset: 1 }); + throw new Error(errorText); + } + + private static findMatch(request: Mock.Request): Mock.Listener | undefined { + for (const listener of Mock.listeners.values()) { + try { + const allMatch = listener.matchers.every((path) => { + try { + const results = JSONPath({ path, json: request, wrap: true }); + return Array.isArray(results) && results.length > 0; + } catch (e) { + Log.writeLine(LogLevels.Warning, `Mock: JSONPath evaluation error in listener <${listener.name}> for path "${path}": ${(e as Error).message} — treating as non-match`); + return false; + } + }); + if (allMatch) return listener; + } catch (e) { + Log.writeLine(LogLevels.Warning, `Mock: Unexpected error evaluating listener <${listener.name}>: ${(e as Error).message} — skipping`); + } + } + return undefined; + } + + private static async fetchReal(request: Mock.Request): Promise { + Log.writeLine(LogLevels.FrameworkDebug, `Mock.fetchReal: ${request.method} ${request.url}`); + try { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body != null ? JSON.stringify(request.body) : undefined, + }); + + const body = await response.text().then((text) => { + try { return JSON.parse(text); } catch { return text; } + }); + + const headers: Record = {}; + response.headers.forEach((value, key) => { headers[key] = value; }); + + Log.writeLine(LogLevels.FrameworkDebug, `Mock.fetchReal: received ${response.status} from ${request.url}`); + return { status: response.status, headers, body }; + } catch (e) { + const message = (e as Error).message; + Log.writeLine(LogLevels.Error, `Mock.fetchReal: network error fetching ${request.url}: ${message}`); + return { status: 502, headers: {}, body: `Mock passthrough network error: ${message}` }; + } + } + + private static record(entry: Omit): void { + Mock.transactions.push({ + id: `txn-${++Mock.transactionCounter}`, + timestamp: new Date(), + ...entry, + }); + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Mock { + /** + * Represents an HTTP request as seen by the interceptor. + * + * The `url`, `method`, and `headers` fields are required. Additional + * properties (e.g. framework-specific metadata) may be included and are + * available to JSONPath matchers. + */ + export interface Request { + /** The fully-qualified request URL. */ + url: string; + /** HTTP method in uppercase, e.g. `'GET'`, `'POST'`. */ + method: string; + /** Request headers as a plain key/value object. */ + headers: Record; + /** Parsed request body, if present. */ + body?: unknown; + /** Any additional properties provided by the framework adapter. */ + [key: string]: unknown; + } + + /** + * Represents an HTTP response returned to the AUT, whether real or mocked. + */ + export interface Response { + /** HTTP status code, e.g. `200`, `404`. Must be 100–599. */ + status: number; + /** Response headers as a plain key/value object. */ + headers?: Record; + /** Response body. Objects are serialised to JSON by the framework adapter. */ + body?: unknown; + } + + /** + * Defines what a listener does when it matches a request. + * + * - `'block'` — abort the request; the AUT receives no response. + * - `'passthrough'` — forward to the real endpoint (or delegate to the caller + * in {@link Mock.delegateMode}) and return the actual response. + * - {@link Response} — return the supplied mock response without hitting the network. + */ + export type ListenerAction = 'block' | 'passthrough' | Response; + + /** + * The result returned by {@link Mock.intercept} to the framework adapter. + * + * - `action: 'fulfill'` — return `response` to the AUT. + * - `action: 'block'` — abort the request; the AUT receives nothing. + * - `action: 'passthrough'` — only in {@link Mock.delegateMode}. Fetch the + * real response using `request` details and report it back via + * {@link Mock.completePassthrough} using the supplied `correlationId`. + */ + export type InterceptResult = + | { action: 'fulfill'; response: Response } + | { action: 'block' } + | { action: 'passthrough'; correlationId: string }; + + /** + * Describes the outcome of a single completed intercepted request. + * + * - `'mocked'` — a mock {@link Response} was returned. + * - `'passthrough'` — the request was forwarded and the real response returned. + * - `'blocked'` — the request was explicitly blocked by a listener. + * - `'unmatched'` — no listener matched; the request was blocked by default. + */ + export type TransactionType = 'mocked' | 'passthrough' | 'blocked' | 'unmatched'; + + /** + * A record of a single completed intercepted request and its outcome. + * + * Delegated passthroughs awaiting completion appear in + * {@link Mock.getPendingTransactions} as {@link PendingTransaction} until + * {@link Mock.completePassthrough} is called. + */ + export interface Transaction { + /** Auto-incremented identifier, e.g. `'txn-1'`. Resets on {@link Mock.reset}. */ + id: string; + /** Wall-clock time at which the request was intercepted. */ + timestamp: Date; + /** Name of the listener that matched, if any. Absent for `'unmatched'` transactions. */ + listenerName?: string; + /** How the request was handled. */ + type: TransactionType; + /** The intercepted request. */ + request: Request; + /** The response returned to the AUT. Absent for `'blocked'` and `'unmatched'` transactions. */ + response?: Response; + } + + /** + * A delegated passthrough that has been issued by {@link Mock.intercept} but + * not yet completed via {@link Mock.completePassthrough}. + * + * Only created when {@link Mock.delegateMode} is `true`. + */ + export interface PendingTransaction { + /** The UUID issued by {@link Mock.intercept} to identify this passthrough. */ + correlationId: string; + /** Wall-clock time at which the passthrough was issued. */ + timestamp: Date; + /** Name of the listener that triggered the passthrough. */ + listenerName: string; + /** The original intercepted request. */ + request: Request; + } + + /** @internal */ + export interface Listener { + name: string; + matchers: string[]; + action: ListenerAction; + delayMs: number; + } +}