@juit/lib-fetch-mock
Version:
Easy Mocking of Node.js' own `fetch`
348 lines (284 loc) • 12.2 kB
text/typescript
import assert from 'node:assert'
import { STATUS_CODES } from 'node:http'
const mockSymbol = Symbol.for('juit.fetch.mock')
type FetchFunction = typeof globalThis.fetch
type FetchArguments = Parameters<FetchFunction>
type MaybeMockedFetchFunction = FetchFunction & { [mockSymbol]?: FetchMockImpl }
type FetchHandlerResult = Response | number | void | null | undefined
type FetchHandler = (request: Request, fetch: FetchFunction) => FetchHandlerResult | Promise<FetchHandlerResult>
// This is an interesting bug somewhere: when using _fetch_ a number of times
// (the real _fetch_), some resources are left open and the testing framework
// never exits.
//
// ON MAC, if we only keep a _hard reference_ to resources (and to the
// created `Response` objects) until the tests end (see `process.on("exit")`
// below) then everything exits gracefully...
//
// ON LINUX, on the other hand, things simply "hang"
//
// I don't like the current "process.exit()" solution, but somehow it seems
// to be the only workable option across platforms...
process.on('exit', () => process.exit(process.exitCode))
/* ========================================================================== *
* FETCH MOCK IMPLEMENTATION *
* ========================================================================== */
export interface FetchMock {
/** Handle requests a specific method and path with the specified handler callback */
on(method: string, path: string | RegExp, handler: FetchHandler): this
/** Handle all requests with the specified handler callback */
handle(handler: FetchHandler): this
/** Intercept mocked requests with an interceptor */
intercept(): () => Promise<DeferredRequest>
/** Reset all handlers and interceptors for this instance */
reset(): this
/** Install this instance as the global `fetch` mock handler */
install(): this
/** Ensure that the mocked `fetch` is restored to its original value */
destroy(): void
}
export interface FetchMockConstructor {
/**
* Create a new {@link FetchMock} instance, optionally resolving relative
* request URLs against the specified _base url_.
*/
new (baseurl?: string | URL | undefined): FetchMock
}
class FetchMockImpl implements FetchMock {
private _handlers: FetchHandler[] = []
private _fetch?: MaybeMockedFetchFunction
private _baseurl: URL
constructor(baseurl?: string | URL | undefined) {
this._baseurl = new URL(baseurl || 'http://test/')
}
/* === FETCH ============================================================== */
private async $fetch(
info: URL | RequestInfo,
init?: RequestInit | undefined,
): Promise<Response> {
assert(this._fetch, 'Global `fetch` not available (mock not enabled?)')
// This has to do with async hooks and resources... By wrapping the real
// fetch, we create a new promise (a new resource) associated with the
// current async context... If we don't do this, sometimes, the process
// won't exit waiting for _something_ (dunno what, yet)!
const realFetch = this._fetch
const fetchWrapper = (
info: URL | RequestInfo,
init?: RequestInit | undefined,
): Promise<Response> => realFetch.call(globalThis, info, init)
let response: Response | undefined = undefined
for (const handler of this._handlers) {
// This is another interesting bug: when using "info.clone()" (before we
// were doing "new Request(info.clone(), init)", _sometimes_ abort
// handlers are not properly propagated down the chain... Dunno why, but
// it seems that creating a new Request using info as init, fixes it.
// Obviously in this case we drop "init", and that might haunt us later...
const request = (info instanceof URL) || (typeof info === 'string') ?
new Request(new URL(info, this._baseurl), init) :
new Request(info.url, info)
const result = await handler(request, fetchWrapper)
if ((result === undefined) || (result === null)) continue
response = (typeof result === 'number') ? sendStatus(result) : result
break
}
// If we have a response from this mock, return it
if (response) return response
// If we are wrapping another mock, call it
if (this._fetch[mockSymbol]) {
return this._fetch[mockSymbol].$fetch(info, init)
}
// Otherwise, return not found (404)
return sendStatus(404)
}
/* === HANDLERS =========================================================== */
on(method: string, path: string | RegExp, handler: FetchHandler): this {
this._handlers.push((request, fetch) => {
if (request.method !== method.toUpperCase()) return
const pathname = new URL(request.url).pathname
if (((typeof path === 'string') && (path === pathname)) ||
((path instanceof RegExp) && pathname.match(path))) {
return handler(request, fetch)
}
})
return this
}
handle(handler: FetchHandler): this {
this._handlers.push(handler)
return this
}
reset(): this {
this._handlers = []
return this
}
/* === INTERCEPTOR ======================================================== */
intercept(): () => Promise<DeferredRequest> {
const queue: Deferred<DeferredRequest>[] = []
queue.push(new Deferred<DeferredRequest>())
const next: () => Promise<DeferredRequest> = () => {
return new Promise<DeferredRequest>((resolve, reject) => {
queue[0]!.promise.then((request) => {
queue.splice(0, 1)
resolve(request)
}, reject)
})
}
this._handlers.push((request: Request, fetch: FetchFunction): Promise<Response> => {
queue.push(new Deferred<DeferredRequest>())
const deferredResponse = new Deferred<Response>()
const deferredRequest = new DeferredRequestImpl(request, deferredResponse, fetch)
queue[queue.length - 2]!.resolve(deferredRequest)
return deferredResponse.promise
})
return next
}
/* === INSTALL / DESTROY ================================================== */
install(): this {
// Ignore this if we are already installed at the root of the mock chain
if (mockSymbol in globalThis.fetch) {
if ((globalThis as any).fetch[mockSymbol] === this) return this
}
// If we are already installed somewhere in the mock chain, fail
let fn: MaybeMockedFetchFunction | undefined = globalThis.fetch
while (fn && fn[mockSymbol]) {
if (fn[mockSymbol] === this) {
throw new Error('Global `fetch` already mocked by this instance')
} else {
fn = fn[mockSymbol]._fetch
}
}
// Save the current fetch (either the real one, or another mock)
this._fetch = globalThis.fetch
// Install our fetch handler
globalThis.fetch = this.$fetch.bind(this)
Object.defineProperty(globalThis.fetch, mockSymbol, { value: this })
return this
}
destroy(): void {
// If we're not mocked *globally*, just return
if (! (mockSymbol in globalThis.fetch)) return
// If we have never been installed, do nothing
if (! this._fetch) return
// Start looking into our chain of mocks
let parent: FetchMockImpl | undefined = undefined
let fn = globalThis.fetch as MaybeMockedFetchFunction
// Walk the chain of mocks until we find ourselves
while (fn[mockSymbol]) {
if (fn[mockSymbol] === this) {
if (! parent) globalThis.fetch = this._fetch
else parent._fetch = this._fetch
break
} else {
parent = fn[mockSymbol]
fn = fn[mockSymbol]._fetch as MaybeMockedFetchFunction
}
}
// Forget our saved fetch
this._fetch = undefined
}
}
export const FetchMock: FetchMockConstructor = FetchMockImpl
/* ========================================================================== *
* DEFERRED REQUESTS *
* ========================================================================== */
/**
* A `DeferredRequest` is an utility class deferring the `Response` sent for
* an _intercepted_ `Request`.
*/
export interface DeferredRequest extends Readonly<Request> {
/** Fail the `Request` with an optional `Error` */
readonly fail: (error?: Error) => void,
/** Respond to this intercepted request with a _real_ `fetch` request */
readonly fetch: (...args: FetchArguments) => void,
/** Respond to the `Request` with an optional `Response` (defaults to an empty 200 `Response`) */
readonly send: (response?: Response | PromiseLike<Response>) => void,
/** Respond to the `Request` with the specified status and an empty body */
readonly sendStatus: (status: number) => void,
/** Respond to the `Request` with the specified JSON body and an optional status */
readonly sendJson: (json: any, status?: number) => void,
/** Respond to the `Request` with the specified text body and an optional status */
readonly sendText: (text: string, status?: number) => void,
/** Respond to the `Request` with the specified binary body and an optional status */
readonly sendData: (data: Uint8Array, status?: number) => void,
}
/**
* A `Deferred` exposes a `Promise`, its `resolve()` and `reject()` functions.
*/
class Deferred<T> {
readonly promise: Promise<T>
readonly resolve: (resolution: T | PromiseLike<T>) => void
readonly reject: (failure: Error) => void
constructor() {
let resolve!: (resolution: T | PromiseLike<T>) => void
let reject!: (failure: Error) => void
this.promise = new Promise<T>((resolver, rejector) => {
resolve = resolver
reject = rejector
})
this.resolve = resolve
this.reject = reject
}
}
/** Implementation of the `DeferredRequest` interface. */
class DeferredRequestImpl extends Request implements DeferredRequest {
constructor(
request: Request,
private readonly _deferred: Deferred<Response>,
private readonly _fetch: FetchFunction,
) {
super(request)
}
fail(failure?: Error): void {
this._deferred.reject(failure || new Error(`Error: ${this.url}`))
}
fetch(...args: FetchArguments): void {
this._deferred.resolve(this._fetch(...args))
}
send(response?: Response | PromiseLike<Response>): void {
this._deferred.resolve(response || new Response())
}
sendStatus(status: number): void {
this._deferred.resolve(sendStatus(status))
}
sendText(text: string, status: number = 200): void {
this._deferred.resolve(sendText(text, status))
}
sendJson(json: any, status: number = 200): void {
this._deferred.resolve(sendJson(json, status))
}
sendData(data: Uint8Array, status = 200): void {
this._deferred.resolve(sendData(data, status))
}
}
/* ========================================================================== *
* EASY RESPONSES *
* ========================================================================== */
/** Create a `Response` with the specified status and an empty body */
export function sendStatus(
status: number,
statusText = STATUS_CODES[status],
): Response {
return new Response(undefined, { status, statusText })
}
/** Create a `Response` with the specified text body and an optional status */
export function sendText(text: string, status = 200): Response {
return new Response(text, {
headers: { 'content-type': 'text/plain; charset=utf-8' },
statusText: STATUS_CODES[status],
status,
})
}
/** Create a `Response` with the specified JSON body and an optional status */
export function sendJson(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
headers: { 'content-type': 'application/json; charset=utf-8' },
statusText: STATUS_CODES[status],
status,
})
}
/** Create a `Response` with the specified binary body and an optional status */
export function sendData(data: Uint8Array, status = 200): Response {
return new Response(Buffer.from(data), {
headers: { 'content-type': 'application/octet-stream' },
statusText: STATUS_CODES[status],
status,
})
}