// // Test harness for the outbound HTTP layer. // // Responses are built from the REAL Response/ReadableStream globals rather than // hand-rolled stubs, so the streaming size cap, the charset decoder and the // body-cancel paths in HttpFetch are genuinely exercised instead of being // stubbed around. // let FetchMock = module.exports = {} let originalFetch = null let routes = [] let callLog = [] FetchMock.install = () => { originalFetch = global.fetch routes = [] callLog = [] global.fetch = jest.fn(async (url, options = {}) => { const href = typeof url === 'string' ? url : url.href callLog.push({ url: href, headers: options.headers || {}, options }) const route = routes.find(entry => entry.match(href)) if (!route) { throw Object.assign(new Error(`No route for ${href}`), { code: 'ENOTFOUND' }) } // A hanging route must still honour the abort signal, or the test would // wait forever instead of proving the AbortController fires. if (options.signal) { return await Promise.race([ Promise.resolve(route.responder(href, options)), abortPromise(options.signal), ]) } return route.responder(href, options) }) } const abortPromise = (signal) => new Promise((resolve, reject) => { if (signal.aborted) { return reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })) } signal.addEventListener('abort', () => { reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })) }, { once: true }) }) FetchMock.restore = () => { if (originalFetch) { global.fetch = originalFetch } else { delete global.fetch } originalFetch = null routes = [] callLog = [] } // // route('https://example.com/', responder) or route(/example/, responder). // Responders are called with (url, options) and may be functions returning a // Response, so a route can answer differently on each call. // FetchMock.route = (pattern, responder) => { const match = pattern instanceof RegExp ? (href) => pattern.test(href) : (href) => href === pattern routes.push({ match, responder: typeof responder === 'function' ? responder : () => responder }) } // Answer with a counter so "retried exactly N times" is assertable. FetchMock.routeSequence = (pattern, responses) => { let index = 0 FetchMock.route(pattern, () => { const response = responses[Math.min(index, responses.length - 1)] index++ return typeof response === 'function' ? response() : response }) } FetchMock.html = (body, { status = 200, headers = {}, contentType = 'text/html; charset=utf-8' } = {}) => { return new Response(body, { status, headers: { 'content-type': contentType, ...headers }, }) } // Raw bytes, for the charset tests where the encoding is the point. FetchMock.buffer = (buffer, { status = 200, contentType = 'text/html', headers = {} } = {}) => { return new Response(buffer, { status, headers: { 'content-type': contentType, ...headers }, }) } FetchMock.status = (status, { headers = {}, body = '' } = {}) => { return new Response(body || null, { status, headers }) } FetchMock.redirect = (location, status = 302) => { return new Response(null, { status, headers: { location } }) } // // A body that never ends. Proves the size cap aborts mid-stream rather than // buffering until the process dies. // FetchMock.oversized = (contentType = 'text/html') => { const chunk = Buffer.alloc(64 * 1024, 0x61) const stream = new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(chunk)) }, }) return new Response(stream, { status: 200, headers: { 'content-type': contentType } }) } // Never settles - the AbortController is the only way out. FetchMock.hang = () => new Promise(() => {}) FetchMock.networkError = (code = 'ECONNRESET') => () => { throw Object.assign(new Error(`socket ${code}`), { code }) } FetchMock.calls = () => callLog.slice() FetchMock.callsTo = (pattern) => callLog.filter(entry => pattern instanceof RegExp ? pattern.test(entry.url) : entry.url === pattern) FetchMock.callCount = () => callLog.length