htmx/test/lib/fetch-mock.js

230 lines
7.0 KiB
JavaScript

/**
* Mock implementation of the Fetch API for testing
*/
class MockResponse {
constructor(body, init = {}) {
this.body = body;
this.status = init.status || 200;
this.ok = this.status >= 200 && this.status < 300;
this.statusText = init.statusText || '';
this.headers = new Map(Object.entries(init.headers || {}));
this.url = init.url || '';
this.type = init.type || 'basic';
}
async json() {
if (typeof this.body === 'string') {
return JSON.parse(this.body);
}
return this.body;
}
async text() {
if (typeof this.body === 'string') {
return this.body;
}
return JSON.stringify(this.body);
}
async blob() {
return new Blob([await this.text()]);
}
async arrayBuffer() {
const text = await this.text();
const encoder = new TextEncoder();
return encoder.encode(text).buffer;
}
clone() {
return new MockResponse(this.body, {
status: this.status,
statusText: this.statusText,
headers: Object.fromEntries(this.headers),
url: this.url,
type: this.type
});
}
}
class FetchMock {
constructor() {
this.reset();
}
reset() {
// Abort all pending requests before clearing
if (this.pendingRequests) {
this.pendingRequests.forEach(({ controller }) => {
if (controller && !controller.signal.aborted) {
controller.abort();
}
});
}
this.calls = [];
this.responses = [];
this.pendingRequests = [];
}
// Record a fetch call
recordCall(url, options) {
this.calls.push({ url, request: options });
}
// Get all recorded calls
getCalls() {
return this.calls;
}
// Get the last call
getLastCall() {
return this.calls[this.calls.length - 1];
}
// Mock a response for a specific URL pattern
mockResponse(method, urlPattern, response, options = {}) {
let upperCasedMethod = method.toUpperCase();
if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].indexOf(upperCasedMethod) < 0) {
throw Error("Invalid HTTP method: " + method)
}
if (typeof response === 'string') {
let str = response;
response = new MockResponse(str, options);
}
this.responses.push({
method: upperCasedMethod,
urlPattern: typeof urlPattern === 'string' ? new RegExp(urlPattern) : urlPattern,
response,
request: options,
once: options.once || false,
used: false
});
}
// Mock a successful JSON response
mockJsonResponse(method, urlPattern, data, status = 200) {
this.mockResponse(method, urlPattern, new MockResponse(data, {
status,
headers: { 'Content-Type': 'application/json' }
}));
}
// Mock an error response
mockErrorResponse(method, urlPattern, status = 500, message = 'Server Error') {
this.mockResponse(method, urlPattern, new MockResponse({ error: message }, { status }));
}
// Mock a network error
mockNetworkError(method, urlPattern, error = new Error('Network Error')) {
this.mockResponse(method, urlPattern, Promise.reject(error));
}
// Mock a network failure (simpler alias)
mockFailure(method, urlPattern, message = 'Network failure') {
this.mockNetworkError(method, urlPattern, new Error(message));
}
// Find matching response
findResponse(method, url) {
for (let i = this.responses.length - 1; i >= 0; i--) {
const mock = this.responses[i];
if (mock.method === method && mock.urlPattern.test(url)) {
if (mock.once) {
if (!mock.used) {
mock.used = true;
return typeof mock.response === 'function' ? mock.response() : mock.response;
}
} else {
return typeof mock.response === 'function' ? mock.response() : mock.response;
}
}
}
console.error("no response configured for ", url, " available responses: ", this.responses);
return "NO RESPONSE CONFIGURED FOR " + url;
}
// Wait for all pending requests to complete
async waitForRequests() {
if (this.pendingRequests.length === 0) {
return Promise.resolve();
}
await Promise.all(this.pendingRequests.map(req => req.promise));
}
// The actual fetch mock function
fetch(url, options = {}) {
this.recordCall(url, options);
options.method = options.method.toUpperCase()
const response = this.findResponse(options.method, url);
// Create an AbortController for this request
const controller = new AbortController();
// Create a tracking object for this request
const pendingRequest = { controller, promise: null };
// Create a promise to track this request
const requestPromise = new Promise((resolve, reject) => {
// Check if already aborted
if (controller.signal.aborted) {
reject(new DOMException('The operation was aborted', 'AbortError'));
return;
}
// Listen for abort
controller.signal.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted', 'AbortError'));
});
// Process the response
Promise.resolve(response instanceof Promise ? response : response)
.then(result => {
if (!controller.signal.aborted) {
resolve(result);
}
})
.catch(error => {
if (!controller.signal.aborted) {
reject(error);
}
});
})
.finally(() => {
// Remove from pending requests when done
const index = this.pendingRequests.indexOf(pendingRequest);
if (index > -1) {
this.pendingRequests.splice(index, 1);
}
});
// Store the promise in the tracking object
pendingRequest.promise = requestPromise;
// Track this pending request
this.pendingRequests.push(pendingRequest);
return requestPromise;
}
}
// Create a global instance
const fetchMock = new FetchMock();
// Install the mock globally
function installFetchMock() {
globalThis.fetch = fetchMock.fetch.bind(fetchMock);
}
// Uninstall the mock and restore original fetch
let originalFetch;
function uninstallFetchMock() {
if (originalFetch) {
globalThis.fetch = originalFetch;
}
}
// Save original fetch before installing
if (typeof globalThis.fetch !== 'undefined') {
originalFetch = globalThis.fetch;
}