diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js index 2298cd2a8..c564eeaf0 100644 --- a/src/mockServiceWorker.js +++ b/src/mockServiceWorker.js @@ -9,15 +9,14 @@ */ const INTEGRITY_CHECKSUM = '' -const bypassHeaderName = 'x-msw-bypass' const activeClientIds = new Set() self.addEventListener('install', function () { - return self.skipWaiting() + self.skipWaiting() }) -self.addEventListener('activate', async function (event) { - return self.clients.claim() +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) }) self.addEventListener('message', async function (event) { @@ -33,7 +32,9 @@ self.addEventListener('message', async function (event) { return } - const allClients = await self.clients.matchAll() + const allClients = await self.clients.matchAll({ + type: 'window', + }) switch (event.data) { case 'KEEPALIVE_REQUEST': { @@ -83,30 +84,58 @@ self.addEventListener('message', async function (event) { } }) -// Resolve the "main" client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' - if (client.frameType === 'top-level') { - return client + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return } - const allClients = await self.clients.matchAll() + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) async function handleRequest(event, requestId) { const client = await resolveMainClient(event) @@ -128,7 +157,7 @@ async function handleRequest(event, requestId) { statusText: clonedResponse.statusText, body: clonedResponse.body === null ? null : await clonedResponse.text(), - headers: serializeHeaders(clonedResponse.headers), + headers: Object.fromEntries(clonedResponse.headers.entries()), redirected: clonedResponse.redirected, }, }) @@ -138,14 +167,54 @@ async function handleRequest(event, requestId) { return response } +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + async function getResponse(event, client, requestId) { const { request } = event - const requestClone = request.clone() - const getOriginalResponse = () => fetch(requestClone) + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the cilent). + const headers = Object.fromEntries(clonedRequest.headers.entries()) - // Bypass mocking when the request client is not active. + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. if (!client) { - return getOriginalResponse() + return passthrough() } // Bypass initial page load requests (i.e. static assets). @@ -153,34 +222,30 @@ async function getResponse(event, client, requestId) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return await getOriginalResponse() + return passthrough() } - // Bypass requests with the explicit bypass header - if (requestClone.headers.get(bypassHeaderName) === 'true') { - const cleanRequestHeaders = serializeHeaders(requestClone.headers) - - // Remove the bypass header to comply with the CORS preflight check. - delete cleanRequestHeaders[bypassHeaderName] - - const originalRequest = new Request(requestClone, { - headers: new Headers(cleanRequestHeaders), - }) - - return fetch(originalRequest) + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() } - // Send the request to the client-side MSW. - const reqHeaders = serializeHeaders(request.headers) - const body = await request.text() + // Create a communication channel scoped to the current request. + // This way events can be exchanged outside of the worker's global + // "message" event listener (i.e. abstracted into functions). + const operationChannel = new BroadcastChannel( + `msw-response-stream-${requestId}`, + ) + // Notify the client that a request has been intercepted. const clientMessage = await sendToClient(client, { type: 'REQUEST', payload: { id: requestId, url: request.url, method: request.method, - headers: reqHeaders, + headers: Object.fromEntries(request.headers.entries()), cache: request.cache, mode: request.mode, credentials: request.credentials, @@ -189,22 +254,23 @@ async function getResponse(event, client, requestId) { redirect: request.redirect, referrer: request.referrer, referrerPolicy: request.referrerPolicy, - body, + body: await request.text(), bodyUsed: request.bodyUsed, keepalive: request.keepalive, }, }) switch (clientMessage.type) { - case 'MOCK_SUCCESS': { - return delayPromise( - () => respondWithMock(clientMessage), - clientMessage.payload.delay, - ) + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.payload) + } + + case 'MOCK_RESPONSE_START': { + return respondWithMockStream(operationChannel, clientMessage.payload) } case 'MOCK_NOT_FOUND': { - return getOriginalResponse() + return passthrough() } case 'NETWORK_ERROR': { @@ -212,7 +278,7 @@ async function getResponse(event, client, requestId) { const networkError = new Error(message) networkError.name = name - // Rejecting a request Promise emulates a network error. + // Rejecting a "respondWith" promise emulates a network error. throw networkError } @@ -231,73 +297,11 @@ This exception has been gracefully handled as a 500 response, however, it's stro request.url, ) - return respondWithMock(clientMessage) + return respondWithMock(clientMessage.payload) } } - return getOriginalResponse() -} - -self.addEventListener('fetch', function (event) { - const { request } = event - const accept = request.headers.get('accept') || '' - - // Bypass server-sent events. - if (accept.includes('text/event-stream')) { - return - } - - // Bypass navigation requests. - if (request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - const requestId = uuidv4() - - return event.respondWith( - handleRequest(event, requestId).catch((error) => { - if (error.name === 'NetworkError') { - console.warn( - '[MSW] Successfully emulated a network error for the "%s %s" request.', - request.method, - request.url, - ) - return - } - - // At this point, any exception indicates an issue with the original request/response. - console.error( - `\ -[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, - request.method, - request.url, - `${error.name}: ${error.message}`, - ) - }), - ) -}) - -function serializeHeaders(headers) { - const reqHeaders = {} - headers.forEach((value, name) => { - reqHeaders[name] = reqHeaders[name] - ? [].concat(reqHeaders[name]).concat(value) - : value - }) - return reqHeaders + return passthrough() } function sendToClient(client, message) { @@ -316,23 +320,48 @@ function sendToClient(client, message) { }) } -function delayPromise(cb, duration) { +function sleep(timeMs) { return new Promise((resolve) => { - setTimeout(() => resolve(cb()), duration) + setTimeout(resolve, timeMs) }) } -function respondWithMock(clientMessage) { - return new Response(clientMessage.payload.body, { - ...clientMessage.payload, - headers: clientMessage.payload.headers, - }) +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) } -function uuidv4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0 - const v = c == 'x' ? r : (r & 0x3) | 0x8 - return v.toString(16) +function respondWithMockStream(operationChannel, mockResponse) { + let streamCtrl + const stream = new ReadableStream({ + start: (controller) => (streamCtrl = controller), + }) + + return new Promise(async (resolve, reject) => { + operationChannel.onmessageerror = (event) => { + operationChannel.close() + return reject(event.data.error) + } + + operationChannel.onmessage = (event) => { + if (!event.data) { + return + } + + switch (event.data.type) { + case 'MOCK_RESPONSE_CHUNK': { + streamCtrl.enqueue(event.data.payload) + break + } + + case 'MOCK_RESPONSE_END': { + streamCtrl.close() + operationChannel.close() + } + } + } + + await sleep(mockResponse.delay) + return resolve(new Response(stream, mockResponse)) }) } diff --git a/src/setupWorker/glossary.ts b/src/setupWorker/glossary.ts index 848b3ca24..417f1dbed 100644 --- a/src/setupWorker/glossary.ts +++ b/src/setupWorker/glossary.ts @@ -5,7 +5,7 @@ import { LifeCycleEventsMap, SharedOptions, } from '../sharedOptions' -import { ServiceWorkerMessage } from '../utils/createBroadcastChannel' +import { ServiceWorkerMessage } from './start/utils/createMessageChannel' import { DefaultBodyType, RequestHandler } from '../handlers/RequestHandler' import type { HttpRequestEventMap, Interceptor } from '@mswjs/interceptors' import { Path } from '../utils/matching/matchRequestUrl' @@ -76,11 +76,19 @@ export type ServiceWorkerOutgoingEventTypes = * Map of the events that can be sent to the Service Worker * only as a part of a single `fetch` event handler. */ -export type ServiceWorkerFetchEventTypes = - | 'MOCK_SUCCESS' - | 'MOCK_NOT_FOUND' - | 'NETWORK_ERROR' - | 'INTERNAL_ERROR' +export interface ServiceWorkerFetchEventMap { + MOCK_RESPONSE(payload: SerializedResponse): void + MOCK_RESPONSE_START(payload: SerializedResponse): void + + MOCK_NOT_FOUND(): void + NETWORK_ERROR(payload: { name: string; message: string }): void + INTERNAL_ERROR(payload: { status: number; body: string }): void +} + +export interface ServiceWorkerBroadcastChannelMessageMap { + MOCK_RESPONSE_CHUNK(payload: Uint8Array): void + MOCK_RESPONSE_END(): void +} export type WorkerLifecycleEventsMap = LifeCycleEventsMap diff --git a/src/setupWorker/setupWorker.ts b/src/setupWorker/setupWorker.ts index fb1e7323f..0ede80180 100644 --- a/src/setupWorker/setupWorker.ts +++ b/src/setupWorker/setupWorker.ts @@ -9,7 +9,7 @@ import { import { createStartHandler } from './start/createStartHandler' import { createStop } from './stop/createStop' import * as requestHandlerUtils from '../utils/internal/requestHandlerUtils' -import { ServiceWorkerMessage } from '../utils/createBroadcastChannel' +import { ServiceWorkerMessage } from './start/utils/createMessageChannel' import { jsonParse } from '../utils/internal/jsonParse' import { RequestHandler } from '../handlers/RequestHandler' import { RestHandler } from '../handlers/RestHandler' diff --git a/src/utils/worker/createFallbackRequestListener.ts b/src/setupWorker/start/createFallbackRequestListener.ts similarity index 91% rename from src/utils/worker/createFallbackRequestListener.ts rename to src/setupWorker/start/createFallbackRequestListener.ts index da7838e80..5d47cf1f1 100644 --- a/src/utils/worker/createFallbackRequestListener.ts +++ b/src/setupWorker/start/createFallbackRequestListener.ts @@ -10,10 +10,10 @@ import { SerializedResponse, SetupWorkerInternalContext, StartOptions, -} from '../../setupWorker/glossary' +} from '../glossary' import type { RequiredDeep } from '../../typeUtils' -import { handleRequest } from '../handleRequest' -import { parseIsomorphicRequest } from '../request/parseIsomorphicRequest' +import { handleRequest } from '../../utils/handleRequest' +import { parseIsomorphicRequest } from '../../utils/request/parseIsomorphicRequest' export function createFallbackRequestListener( context: SetupWorkerInternalContext, diff --git a/src/setupWorker/start/createFallbackStart.ts b/src/setupWorker/start/createFallbackStart.ts index f387f147c..cdc88bd61 100644 --- a/src/setupWorker/start/createFallbackStart.ts +++ b/src/setupWorker/start/createFallbackStart.ts @@ -1,4 +1,4 @@ -import { createFallbackRequestListener } from '../../utils/worker/createFallbackRequestListener' +import { createFallbackRequestListener } from './createFallbackRequestListener' import { SetupWorkerInternalContext, StartHandler } from '../glossary' import { printStartMessage } from './utils/printStartMessage' diff --git a/src/utils/worker/createRequestListener.ts b/src/setupWorker/start/createRequestListener.ts similarity index 65% rename from src/utils/worker/createRequestListener.ts rename to src/setupWorker/start/createRequestListener.ts index eaf356e77..4e3a32bdd 100644 --- a/src/utils/worker/createRequestListener.ts +++ b/src/setupWorker/start/createRequestListener.ts @@ -3,17 +3,20 @@ import { SerializedResponse, SetupWorkerInternalContext, ServiceWorkerIncomingEventsMap, -} from '../../setupWorker/glossary' + ServiceWorkerBroadcastChannelMessageMap, +} from '../glossary' import { ServiceWorkerMessage, - createBroadcastChannel, -} from '../createBroadcastChannel' -import { NetworkError } from '../NetworkError' -import { parseWorkerRequest } from '../request/parseWorkerRequest' -import { handleRequest } from '../handleRequest' + createMessageChannel, +} from './utils/createMessageChannel' +import { NetworkError } from '../../utils/NetworkError' +import { parseWorkerRequest } from '../../utils/request/parseWorkerRequest' +import { handleRequest } from '../../utils/handleRequest' import { RequestHandler } from '../../handlers/RequestHandler' import { RequiredDeep } from '../../typeUtils' import { MockedResponse } from '../../response' +import { streamResponse } from './utils/streamResponse' +import { StrictBroadcastChannel } from '../../utils/internal/StrictBroadcastChannel' export const createRequestListener = ( context: SetupWorkerInternalContext, @@ -26,10 +29,15 @@ export const createRequestListener = ( ServiceWorkerIncomingEventsMap['REQUEST'] >, ) => { - const channel = createBroadcastChannel(event) + const messageChannel = createMessageChannel(event) try { const request = parseWorkerRequest(message.payload) + const operationChannel = + new StrictBroadcastChannel( + `msw-response-stream-${request.id}`, + ) + await handleRequest( request, context.requestHandlers, @@ -38,15 +46,22 @@ export const createRequestListener = ( { transformResponse, onPassthroughResponse() { - return channel.send({ + return messageChannel.send({ type: 'MOCK_NOT_FOUND', }) }, onMockedResponse(response) { - channel.send({ - type: 'MOCK_SUCCESS', - payload: response, - }) + // Signal the mocked responses without bodies immediately. + // There is nothing to stream, so no need to initiate streaming. + if (response.body == null) { + return messageChannel.send({ + type: 'MOCK_RESPONSE', + payload: response, + }) + } + + // If the mocked response has a body, stream it to the worker. + streamResponse(operationChannel, messageChannel, response) }, onMockedResponseSent( response, @@ -69,7 +84,7 @@ export const createRequestListener = ( if (error instanceof NetworkError) { // Treat emulated network error differently, // as it is an intended exception in a request handler. - return channel.send({ + return messageChannel.send({ type: 'NETWORK_ERROR', payload: { name: error.name, @@ -81,7 +96,7 @@ export const createRequestListener = ( if (error instanceof Error) { // Treat all the other exceptions in a request handler // as unintended, alerting that there is a problem needs fixing. - channel.send({ + messageChannel.send({ type: 'INTERNAL_ERROR', payload: { status: 500, diff --git a/src/utils/worker/createResponseListener.ts b/src/setupWorker/start/createResponseListener.ts similarity index 94% rename from src/utils/worker/createResponseListener.ts rename to src/setupWorker/start/createResponseListener.ts index 24dfd7034..6fa37ad89 100644 --- a/src/utils/worker/createResponseListener.ts +++ b/src/setupWorker/start/createResponseListener.ts @@ -2,7 +2,7 @@ import { ServiceWorkerIncomingEventsMap, SetupWorkerInternalContext, } from '../../setupWorker/glossary' -import { ServiceWorkerMessage } from '../createBroadcastChannel' +import { ServiceWorkerMessage } from './utils/createMessageChannel' export function createResponseListener(context: SetupWorkerInternalContext) { return ( diff --git a/src/setupWorker/start/createStartHandler.ts b/src/setupWorker/start/createStartHandler.ts index c0e3022e3..c7a127593 100644 --- a/src/setupWorker/start/createStartHandler.ts +++ b/src/setupWorker/start/createStartHandler.ts @@ -2,10 +2,10 @@ import { until } from '@open-draft/until' import { getWorkerInstance } from './utils/getWorkerInstance' import { enableMocking } from './utils/enableMocking' import { SetupWorkerInternalContext, StartHandler } from '../glossary' -import { createRequestListener } from '../../utils/worker/createRequestListener' +import { createRequestListener } from './createRequestListener' import { requestIntegrityCheck } from '../../utils/internal/requestIntegrityCheck' import { deferNetworkRequestsUntil } from '../../utils/deferNetworkRequestsUntil' -import { createResponseListener } from '../../utils/worker/createResponseListener' +import { createResponseListener } from './createResponseListener' import { validateWorkerScope } from './utils/validateWorkerScope' import { devUtils } from '../../utils/internal/devUtils' @@ -25,6 +25,7 @@ export const createStartHandler = ( createRequestListener(context, options), ) + // Handle responses signaled by the worker. context.workerChannel.on('RESPONSE', createResponseListener(context)) const instance = await getWorkerInstance( diff --git a/src/setupWorker/start/utils/createMessageChannel.ts b/src/setupWorker/start/utils/createMessageChannel.ts new file mode 100644 index 000000000..912cac54d --- /dev/null +++ b/src/setupWorker/start/utils/createMessageChannel.ts @@ -0,0 +1,46 @@ +import { + ServiceWorkerFetchEventMap, + ServiceWorkerIncomingEventsMap, +} from '../../glossary' + +export interface ServiceWorkerMessage< + EventType extends keyof ServiceWorkerIncomingEventsMap, + EventPayload, +> { + type: EventType + payload: EventPayload +} + +export interface WorkerMessageChannel { + send( + message: Parameters[0] extends undefined + ? { type: Event } + : { + type: Event + payload: Parameters[0] + }, + ): void +} + +/** + * Creates a communication channel between the client + * and the Service Worker associated with the given event. + */ +export function createMessageChannel( + event: MessageEvent, +): WorkerMessageChannel { + const port = event.ports[0] + + return { + /** + * Send a text message to the connected Service Worker. + */ + send(message) { + if (!port) { + return + } + + port.postMessage(message) + }, + } +} diff --git a/src/setupWorker/start/utils/streamResponse.ts b/src/setupWorker/start/utils/streamResponse.ts new file mode 100644 index 000000000..8f8b46a5a --- /dev/null +++ b/src/setupWorker/start/utils/streamResponse.ts @@ -0,0 +1,56 @@ +import { invariant } from 'outvariant' +import { StrictBroadcastChannel } from '../../../utils/internal/StrictBroadcastChannel' +import { + SerializedResponse, + ServiceWorkerBroadcastChannelMessageMap, +} from '../../glossary' +import { WorkerMessageChannel } from './createMessageChannel' + +export async function streamResponse( + operationChannel: StrictBroadcastChannel, + messageChannel: WorkerMessageChannel, + mockedResponse: SerializedResponse, +): Promise { + const response = new Response(mockedResponse.body, mockedResponse) + + /** + * Delete the ReadableStream response body + * so it doesn't get sent via the message channel. + * @note Otherwise, an error: cannot clone a ReadableStream if + * it hasn't been transformed yet. + */ + delete mockedResponse.body + + // Signal the mock response stream start event on the global + // message channel because the worker expects an event in response + // to the sent "REQUEST" global event. + messageChannel.send({ + type: 'MOCK_RESPONSE_START', + payload: mockedResponse, + }) + + invariant(response.body, 'Failed to stream mocked response with no body') + + // Read the mocked response body as stream + // and pipe it to the worker. + const reader = response.body.getReader() + + while (true) { + const { done, value } = await reader.read() + + if (!done) { + operationChannel.postMessage({ + type: 'MOCK_RESPONSE_CHUNK', + payload: value, + }) + continue + } + + operationChannel.postMessage({ + type: 'MOCK_RESPONSE_END', + }) + operationChannel.close() + reader.releaseLock() + break + } +} diff --git a/src/utils/createBroadcastChannel.ts b/src/utils/createBroadcastChannel.ts deleted file mode 100644 index a11d67e78..000000000 --- a/src/utils/createBroadcastChannel.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - ServiceWorkerFetchEventTypes, - ServiceWorkerIncomingEventsMap, -} from '../setupWorker/glossary' - -export interface ServiceWorkerMessage< - EventType extends keyof ServiceWorkerIncomingEventsMap, - EventPayload, -> { - type: EventType - payload: EventPayload -} - -/** - * Creates a communication channel between the client - * and the Service Worker associated with the given event. - */ -export const createBroadcastChannel = (event: MessageEvent) => { - const port = event.ports[0] - - return { - /** - * Sends a text message to the connected Service Worker. - */ - send(message: { - type: ServiceWorkerFetchEventTypes - payload?: Record | string - }) { - if (port) { - port.postMessage(message) - } - }, - } -} diff --git a/src/utils/internal/StrictBroadcastChannel.ts b/src/utils/internal/StrictBroadcastChannel.ts new file mode 100644 index 000000000..15b7237e4 --- /dev/null +++ b/src/utils/internal/StrictBroadcastChannel.ts @@ -0,0 +1,27 @@ +const ParentClass = + typeof BroadcastChannel == 'undefined' + ? class UnsupportedEnvironment { + constructor() { + throw new Error( + 'Cannot construct BroadcastChannel in a non-browser environment', + ) + } + } + : BroadcastChannel + +export class StrictBroadcastChannel< + MessageMap extends Record, +> extends (ParentClass as unknown as { new (name: string): BroadcastChannel }) { + public postMessage( + message: Parameters[0] extends undefined + ? { + type: MessageType + } + : { + type: MessageType + payload: Parameters[0] + }, + ): void { + return super.postMessage(message) + } +} diff --git a/test/msw-api/context/delay.node.test.ts b/test/msw-api/context/delay.node.test.ts index 279e8fbc2..adc685ae1 100644 --- a/test/msw-api/context/delay.node.test.ts +++ b/test/msw-api/context/delay.node.test.ts @@ -53,7 +53,6 @@ test('uses realistic server response time when no duration is provided', async ( // Realistic server response time in Node.js is set to 5ms. expect(responseTime).toBeGreaterThan(5) - expect(responseTime).toBeLessThan(100) expect(await res.text()).toBe('john') })