mock-xmlhttprequest
Version:
XMLHttpRequest mock for testing
291 lines (286 loc) • 10.2 kB
JavaScript
/**
* 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;