UNPKG

mock-xmlhttprequest

Version:
291 lines (286 loc) 10.2 kB
/** * mock-xmlhttprequest v8.4.1 * (c) 2025 Bertrand Guay-Paquet * @license MIT */ 'use strict'; const Utils = require('./Utils.cjs'); /** * Mock server for responding to XMLHttpRequest mocks from the class MockXhr. Provides simple route * matching and request handlers to make test harness creation easier. */ class MockXhrServer { /** * Constructor * * @param xhrMock XMLHttpRequest mock class * @param routes Routes */ constructor(xhrMock, routes) { this.progressRate = 0; this._MockXhr = xhrMock; this._requests = []; this._routes = {}; if (routes) { Object.entries(routes).forEach(([method, [urlMatcher, handler]]) => { this.addHandler(method, urlMatcher, handler); }); } xhrMock.onSend = (request) => { this._handleRequest(request); }; // Setup a mock request factory for users this._xhrFactory = () => new this._MockXhr(); } get MockXhr() { return this._MockXhr; } /** * For backwards compatibility with versions < 4.1.0 * * @deprecated Use the MockXhr property instead */ get xhrMock() { return this._MockXhr; } get xhrFactory() { return this._xhrFactory; } /** * Install the server's XMLHttpRequest mock in the global context. You can specify a different * context with the optional `context` argument. Revert with remove(). * * @param context Context object (e.g. global, window) * @returns this */ install(context = globalThis) { this._savedContext = context; // Distinguish between an undefined and a missing XMLHttpRequest property if ('XMLHttpRequest' in context) { this._savedContextHadXMLHttpRequest = true; this._savedXMLHttpRequest = context.XMLHttpRequest; } else { this._savedContextHadXMLHttpRequest = false; } context.XMLHttpRequest = this._MockXhr; return this; } /** * Revert the changes made by install(). Call this after your tests. */ remove() { if (!this._savedContext) { throw new Error('remove() called without a matching install(context).'); } if (this._savedContextHadXMLHttpRequest) { this._savedContext.XMLHttpRequest = this._savedXMLHttpRequest; delete this._savedXMLHttpRequest; } else { delete this._savedContext.XMLHttpRequest; } delete this._savedContext; } /** * Disable the effects of the timeout attribute on the XMLHttpRequest mock used by the server. * * @returns this */ disableTimeout() { this._MockXhr.timeoutEnabled = false; return this; } /** * Enable the effects of the timeout attribute on the XMLHttpRequest mock used by the server. * * @returns this */ enableTimeout() { this._MockXhr.timeoutEnabled = true; return this; } /** * Add a GET request handler. * * @param urlMatcher Url matcher * @param handler Request handler * @returns this */ get(urlMatcher, handler) { return this.addHandler('GET', urlMatcher, handler); } /** * Add a POST request handler. * * @param urlMatcher Url matcher * @param handler Request handler * @returns this */ post(urlMatcher, handler) { return this.addHandler('POST', urlMatcher, handler); } /** * Add a PUT request handler. * * @param urlMatcher Url matcher * @param handler Request handler * @returns this */ put(urlMatcher, handler) { return this.addHandler('PUT', urlMatcher, handler); } /** * Add a DELETE request handler. * * @param urlMatcher Url matcher * @param handler Request handler * @returns this */ delete(urlMatcher, handler) { return this.addHandler('DELETE', urlMatcher, handler); } /** * Add a request handler. * * @param method HTTP method * @param urlMatcher Url matcher * @param handler Request handler * @returns this */ addHandler(method, urlMatcher, handler) { // Match the processing done in MockXHR for the method name method = Utils.normalizeHTTPMethodName(method); const routes = this._routes[method] ?? (this._routes[method] = []); routes.push({ urlMatcher, handler, count: 0 }); return this; } /** * Set the default request handler for requests that don't match any route. * * @param handler Request handler * @returns this */ setDefaultHandler(handler) { this._defaultRoute = { handler, count: 0 }; return this; } /** * Return 404 responses for requests that don't match any route. * * @returns this */ setDefault404() { return this.setDefaultHandler({ status: 404 }); } /** * @returns Array of requests received by the server. Entries: { method, url, headers, body? } */ getRequestLog() { return [...this._requests]; } _handleRequest(request) { // Record the request for easier debugging this._requests.push({ method: request.method, url: request.url, headers: request.requestHeaders.getHash(), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment body: request.body, }); const route = this._findFirstMatchingRoute(request) ?? this._defaultRoute; if (route) { // Routes can have arrays of handlers. Each one is used once and the last one is used if out // of elements. const handler = Array.isArray(route.handler) ? route.handler[Math.min(route.handler.length - 1, route.count)] : route.handler; route.count += 1; if (typeof handler === 'function') { handler(request); } else if (handler === 'error') { request.setNetworkError(); } else if (handler === 'timeout') { request.setRequestTimeout(); } else { const responseHeaders = { ...handler.headers }; const responseBodySize = Utils.getBodyByteSize(handler.body); // Add the Content-Length header if it's not present. if (!Object.keys(responseHeaders).some((k) => k.toUpperCase() === 'CONTENT-LENGTH')) { responseHeaders['content-length'] = String(responseBodySize); } if (this.progressRate <= 0) { request.respond(handler.status, responseHeaders, handler.body, handler.statusText); } else { let responseTransmitted = 0; const responsePhase = () => { if (responseTransmitted === 0) { request.setResponseHeaders(handler.status, responseHeaders, handler.statusText); } if (this.progressRate <= 0) { // Final operation for this request request.setResponseBody(handler.body); } else { const nextTransmitted = responseTransmitted + this.progressRate; if (nextTransmitted < responseBodySize) { responseTransmitted = nextTransmitted; request.downloadProgress(responseTransmitted, responseBodySize); void Promise.resolve().then(() => { responsePhase(); }); } else { // Final operation for this request request.setResponseBody(handler.body); } } }; const requestBodySize = request.getRequestBodySize(); if (requestBodySize === 0) { responsePhase(); } else { let requestTransmitted = 0; const requestPhase = () => { if (this.progressRate <= 0) { // Final operation for this request request.respond(handler.status, responseHeaders, handler.body, handler.statusText); } else { const nextTransmitted = requestTransmitted + this.progressRate; if (nextTransmitted < requestBodySize) { requestTransmitted = nextTransmitted; request.uploadProgress(requestTransmitted); void Promise.resolve().then(() => { requestPhase(); }); } else { responsePhase(); } } }; requestPhase(); } } } } } _findFirstMatchingRoute(request) { const method = Utils.normalizeHTTPMethodName(request.method); if (!this._routes[method]) { return undefined; } const { url } = request; return this._routes[method].find((route) => { const { urlMatcher } = route; if (typeof urlMatcher === 'function') { return urlMatcher(url); } else if (urlMatcher instanceof RegExp) { return urlMatcher.test(url); } return urlMatcher === url; }); } } module.exports = MockXhrServer;