128 lines
3.9 KiB
JavaScript
128 lines
3.9 KiB
JavaScript
//
|
|
// 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
|