From 8be9ad9331dacdcad7b6c2c124149b2142b7c741 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Nov 2024 19:42:58 +0100 Subject: [PATCH] fix(bypass): support `sendBeacon()` requests --- src/core/bypass.test.ts | 12 +++++ src/core/bypass.ts | 3 +- src/core/utils/handleRequest.test.ts | 8 ++-- src/core/utils/handleRequest.ts | 4 +- src/mockServiceWorker.js | 14 +++--- test/browser/rest-api/send-beacon.mocks.ts | 14 ++++++ test/browser/rest-api/send-beacon.test.ts | 56 ++++++++++++++++++++++ 7 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 test/browser/rest-api/send-beacon.mocks.ts create mode 100644 test/browser/rest-api/send-beacon.test.ts diff --git a/src/core/bypass.test.ts b/src/core/bypass.test.ts index bef858bcb..4b122a7f8 100644 --- a/src/core/bypass.test.ts +++ b/src/core/bypass.test.ts @@ -67,3 +67,15 @@ it('allows modifying the bypassed request instance', async () => { expect(await request.text()).toBe('hello world') expect(original.bodyUsed).toBe(false) }) + +it('supports bypassing "keepalive: true" requests', async () => { + const original = new Request('http://localhost/resource', { + method: 'POST', + keepalive: true, + }) + const request = bypass(original) + + expect(request.method).toBe('POST') + expect(request.url).toBe('http://localhost/resource') + expect(request.body).toBeNull() +}) diff --git a/src/core/bypass.ts b/src/core/bypass.ts index 26d3afb2f..7710b2fed 100644 --- a/src/core/bypass.ts +++ b/src/core/bypass.ts @@ -38,7 +38,8 @@ export function bypass(input: BypassRequestInput, init?: RequestInit): Request { // to bypass this request from any further request matching. // Unlike "passthrough()", bypass is meant for performing // additional requests within pending request resolution. - requestClone.headers.set('x-msw-intention', 'bypass') + + requestClone.headers.append('accept', 'msw/passthrough') return requestClone } diff --git a/src/core/utils/handleRequest.test.ts b/src/core/utils/handleRequest.test.ts index 5e9e896c5..c8d87d617 100644 --- a/src/core/utils/handleRequest.test.ts +++ b/src/core/utils/handleRequest.test.ts @@ -46,13 +46,13 @@ afterEach(() => { vi.resetAllMocks() }) -test('returns undefined for a request with the "x-msw-intention" header equal to "bypass"', async () => { +test('returns undefined for a request with the "accept: msw/passthrough" header equal to "bypass"', async () => { const { emitter, events } = setup() const requestId = createRequestId() const request = new Request(new URL('http://localhost/user'), { headers: new Headers({ - 'x-msw-intention': 'bypass', + accept: 'msw/passthrough', }), }) const handlers: Array = [] @@ -79,12 +79,12 @@ test('returns undefined for a request with the "x-msw-intention" header equal to expect(handleRequestOptions.onMockedResponse).not.toHaveBeenCalled() }) -test('does not bypass a request with "x-msw-intention" header set to arbitrary value', async () => { +test('does not bypass a request with "accept: msw/*" header set to arbitrary value', async () => { const { emitter } = setup() const request = new Request(new URL('http://localhost/user'), { headers: new Headers({ - 'x-msw-intention': 'invalid', + acceot: 'msw/invalid', }), }) const handlers: Array = [ diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index 1f5780bbc..b7cd2599e 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -46,8 +46,8 @@ export async function handleRequest( ): Promise { emitter.emit('request:start', { request, requestId }) - // Perform bypassed requests (i.e. wrapped in "bypass()") as-is. - if (request.headers.get('x-msw-intention') === 'bypass') { + // Perform requests wrapped in "bypass()" as-is. + if (request.headers.get('accept')?.includes('msw/passthrough')) { emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js index 18592f3d5..364d44023 100644 --- a/src/mockServiceWorker.js +++ b/src/mockServiceWorker.js @@ -192,12 +192,14 @@ async function getResponse(event, client, requestId) { const requestClone = request.clone() function passthrough() { - const headers = Object.fromEntries(requestClone.headers.entries()) - - // Remove internal MSW request header so the passthrough request - // complies with any potential CORS preflight checks on the server. - // Some servers forbid unknown request headers. - delete headers['x-msw-intention'] + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + headers.delete('accept', 'msw/passthrough') return fetch(requestClone, { headers }) } diff --git a/test/browser/rest-api/send-beacon.mocks.ts b/test/browser/rest-api/send-beacon.mocks.ts new file mode 100644 index 000000000..6eb46ceca --- /dev/null +++ b/test/browser/rest-api/send-beacon.mocks.ts @@ -0,0 +1,14 @@ +import { http, bypass } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.post('/analytics', ({ request }) => { + return new Response(request.body) + }), + http.post('*/analytics-bypass', ({ request }) => { + const nextRequest = bypass(request) + return fetch(nextRequest) + }), +) + +worker.start() diff --git a/test/browser/rest-api/send-beacon.test.ts b/test/browser/rest-api/send-beacon.test.ts new file mode 100644 index 000000000..1d57f7bc6 --- /dev/null +++ b/test/browser/rest-api/send-beacon.test.ts @@ -0,0 +1,56 @@ +import { test, expect } from '../playwright.extend' + +test('supports mocking a response to a "sendBeacon" request', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./send-beacon.mocks.ts')) + + const isQueuedPromise = page.evaluate(() => { + return navigator.sendBeacon( + '/analytics', + JSON.stringify({ event: 'pageview' }), + ) + }) + + const response = await page.waitForResponse((response) => { + return response.url().endsWith('/analytics') + }) + + expect(response.status()).toBe(200) + // Technically, "sendBeacon" responses don't send any body back. + // We use this body only to verify that the request body was accessible + // in the request handlers. + await expect(response.text()).resolves.toBe('{"event":"pageview"}') + + // Must return true, indicating that the server has queued the sent data. + await expect(isQueuedPromise).resolves.toBe(true) +}) + +test.only('supports bypassing "sendBeacon" requests', async ({ + loadExample, + page, +}) => { + const { compilation } = await loadExample( + require.resolve('./send-beacon.mocks.ts'), + { + beforeNavigation(compilation) { + compilation.use((router) => { + router.post('/analytics-bypass', (_req, res) => { + res.status(200).end() + }) + }) + }, + }, + ) + + const url = new URL('./analytics-bypass', compilation.previewUrl).href + const isQueuedPromise = page.evaluate((url) => { + return navigator.sendBeacon(url, JSON.stringify({ event: 'pageview' })) + }, url) + + const response = await page.waitForResponse(url) + expect(response.status()).toBe(200) + + await expect(isQueuedPromise).resolves.toBe(true) +})