Skip to content

Commit

Permalink
feat: add support for MSW 2.x (#77)
Browse files Browse the repository at this point in the history
* feat: upgrade to msw 2.x

* chore: get all tests to pass

* chore: update README.md

* chore: bump peer dependencies

* chore: update file name to reflect msw api change

* chore: update yarn.lock

* chore: set required node version to >=18

BREAKING CHANGE: Due to the updated dependency msw 2, using
playwright-msw requires node 18 or newer

* refactor: use crypto's randomUUID instead of rolling it ourselves

* fix: actually log active handlers

* docs: use "realistic" delay in examples

* refactor: use HttpResponse instead of Response for future extensibility

* chore: add yarn cache to gitignore

* docs: remove redundant word

* refactor: use static factory where applicable

* chore: remove unnecessary isNullArray

---------

Co-authored-by: Martin Dennhardt <[email protected]>
  • Loading branch information
valendres and mamidenn authored Dec 5, 2023
1 parent 955f863 commit cb7d144
Show file tree
Hide file tree
Showing 32 changed files with 1,633 additions and 1,269 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
14.18.1
lts/hydrogen
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ node_modules
.gitignore
.gitkeep
.github/CODEOWNERS
.nvmrc
*.svg
*.zip
4 changes: 2 additions & 2 deletions packages/example-vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
},
"devDependencies": {
"@playwright/test": "^1.30.0",
"@vitejs/plugin-vue": "^4.0.0",
"msw": "1.0.0",
"@vitejs/plugin-vue": "^4.5.0",
"msw": "2.0.8",
"playwright-msw": "workspace:*",
"typescript": "4.9.5",
"vite": "4.1.1",
Expand Down
5 changes: 3 additions & 2 deletions packages/example-vue/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { testHandlers } from './mocks/handler';
import { createApp } from 'vue';
import App from './App.vue';
import { setupWorker } from 'msw';
import { setupWorker } from 'msw/browser';

const worker = setupWorker(...testHandlers);

Expand All @@ -11,7 +11,8 @@ async function prepare() {

return worker.start({}).then(() => {
console.groupCollapsed('[MSW] Loaded with handlers 🎉');
worker.printHandlers();
const handlers = worker.listHandlers();
handlers.forEach((handler) => console.log(handler.info.header));
console.groupEnd();
return null;
});
Expand Down
21 changes: 13 additions & 8 deletions packages/example-vue/src/mocks/handler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { rest } from 'msw';
import { http, delay, HttpResponse } from 'msw';

export const testHandlers = [
rest.get('/api/users', async (_, response, context) => {
return response(
context.status(200),
context.delay(500),
context.json([{ name: 'Harry' }, { name: 'Ron' }, { name: 'Hermione' }])
);
}),
http.get(
'/api/users',

async () => {
await delay();
return HttpResponse.json([
{ name: 'Harry' },
{ name: 'Ron' },
{ name: 'Hermione' },
]);
}
),
];
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { rest } from 'msw';
import { delay, http, HttpResponse } from 'msw';
import { expect, test } from '../test';

test.describe.parallel('REST', () => {
test.describe.parallel('HTTP', () => {
test('should use the default handlers without requiring handlers to be specified on a per-test basis', async ({
page,
}) => {
Expand All @@ -14,13 +14,10 @@ test.describe.parallel('REST', () => {
worker,
}) => {
await worker.use(
rest.get('/api/users', (_, response, context) =>
response(
context.delay(250),
context.status(200),
context.json([{ name: 'Custom User' }])
)
)
http.get('/api/users', async () => {
await delay(250);
return HttpResponse.json([{ name: 'Custom User' }]);
})
);
await page.goto('/users');
await expect(page.locator('text="Custom User"')).toBeVisible();
Expand Down
4 changes: 2 additions & 2 deletions packages/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ The React SPA utilises [React Router](https://reactrouter.com/en/main) to serve
| Route | Component | Test | Description |
| ---------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [/login](http://127.0.0.1:5173/login) | [LoginForm](./src/components/login-form.tsx) | [cookies.spec.ts](./tests/playwright/specs/cookies.spec.ts) | An example login form that's used to test the behaviour of cookies. It makes sure that cookies are passed to the MSW handlers and are not unexpectedly leaked across test runs. |
| [/users](http://127.0.0.1:5173/users) | [UsersList](./src/components//users-list.tsx) | [rest.spec.ts](./tests/playwright/specs/rest.spec.ts) | A simple list of users which is used to test the behaviour of REST handlers. It utilises [ReactQuery](https://tanstack.com/query) to perform the API calls. |
| [/users/:userId](http://127.0.0.1:5173/users/b44e89e4-3254-415e-b14a-441166616b20) | [UserProfile](./src/components/user-profile.tsx) | [rest.spec.ts](./tests/playwright/specs/rest.spec.ts) | A simple user page that shows more details about the user than the `UserList`. It is used to make sure that [Route parameters](https://expressjs.com/en/guide/routing.html#route-parameters) work as expected. |
| [/users](http://127.0.0.1:5173/users) | [UsersList](./src/components//users-list.tsx) | [http.spec.ts](./tests/playwright/specs/http.spec.ts) | A simple list of users which is used to test the behaviour of REST handlers. It utilises [ReactQuery](https://tanstack.com/query) to perform the API calls. |
| [/users/:userId](http://127.0.0.1:5173/users/b44e89e4-3254-415e-b14a-441166616b20) | [UserProfile](./src/components/user-profile.tsx) | [http.spec.ts](./tests/playwright/specs/http.spec.ts) | A simple user page that shows more details about the user than the `UserList`. It is used to make sure that [Route parameters](https://expressjs.com/en/guide/routing.html#route-parameters) work as expected. |
| [/settings](http://127.0.0.1:5173/settings) | [SettingsForm](./src/components/settings-form.tsx) | [graphql.spec.ts](./tests/playwright/specs/graphql.ts) | A basic settings page to make sure that GraphQL queries and mutations work. It utilises [Apollo Client](https://www.apollographql.com/docs/react/) to perform the API calls. |
| [/documents](http://127.0.0.1:5173/documents) | [Documents](./src/components/documents.tsx) | [handler-priority.spec.ts](./tests/playwright/specs/handler-priority.ts) | A list of documents which is used to test the order in which handlers are processed when there are multiple matching handlers for a single route. |
| [/search](http://127.0.0.1:5173/search) | [Search](./src/components/search.tsx) | [query-parameters.spec.ts](./tests/playwright/specs/query-parameters.ts) | A basic search engine page that allows the behaviour of query parameters to be tested. |
Expand Down
177 changes: 83 additions & 94 deletions packages/example/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
/* tslint:disable */

/**
* Mock Service Worker (0.47.4).
* Mock Service Worker (2.0.8).
* @see https:/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/

const INTEGRITY_CHECKSUM = 'b3066ef78c2f9090b4ce87e874965995';
const INTEGRITY_CHECKSUM = '0877fcdc026242810f5bfde0d7178db4';
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
const activeClientIds = new Set();

self.addEventListener('install', function () {
Expand Down Expand Up @@ -86,12 +87,6 @@ self.addEventListener('message', async function (event) {

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') {
Expand All @@ -112,29 +107,8 @@ self.addEventListener('fetch', function (event) {
}

// 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}`
);
})
);
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId));
});

async function handleRequest(event, requestId) {
Expand All @@ -146,21 +120,29 @@ async function handleRequest(event, requestId) {
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
(async function () {
const clonedResponse = response.clone();
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
const responseClone = response.clone();
// When performing original requests, response body will
// always be a ReadableStream, even for 204 responses.
// But when creating a new Response instance on the client,
// the body for a 204 response must be null.
const responseBody = response.status === 204 ? null : responseClone.body;

sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseBody,
headers: Object.fromEntries(responseClone.headers.entries()),
},
},
});
[responseBody]
);
})();
}

Expand All @@ -174,7 +156,7 @@ async function handleRequest(event, requestId) {
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);

if (client.frameType === 'top-level') {
if (client?.frameType === 'top-level') {
return client;
}

Expand All @@ -196,20 +178,20 @@ async function resolveMainClient(event) {

async function getResponse(event, client, requestId) {
const { request } = event;
const clonedRequest = request.clone();

// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = 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 client).
const headers = Object.fromEntries(clonedRequest.headers.entries());
const headers = Object.fromEntries(requestClone.headers.entries());

// 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'];
// 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'];

return fetch(clonedRequest, { headers });
return fetch(requestClone, { headers });
}

// Bypass mocking when the client is not active.
Expand All @@ -227,31 +209,36 @@ async function getResponse(event, client, requestId) {

// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
const mswIntention = request.headers.get('x-msw-intention');
if (['bypass', 'passthrough'].includes(mswIntention)) {
return passthrough();
}

// 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: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
const requestBuffer = await request.arrayBuffer();
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
},
},
});
[requestBuffer]
);

switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
Expand All @@ -261,21 +248,12 @@ async function getResponse(event, client, requestId) {
case 'MOCK_NOT_FOUND': {
return passthrough();
}

case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data;
const networkError = new Error(message);
networkError.name = name;

// Rejecting a "respondWith" promise emulates a network error.
throw networkError;
}
}

return passthrough();
}

function sendToClient(client, message) {
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();

Expand All @@ -287,17 +265,28 @@ function sendToClient(client, message) {
resolve(event.data);
};

client.postMessage(message, [channel.port2]);
client.postMessage(
message,
[channel.port2].concat(transferrables.filter(Boolean))
);
});
}

function sleep(timeMs) {
return new Promise((resolve) => {
setTimeout(resolve, timeMs);
async function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}

const mockedResponse = new Response(response.body, response);

Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
}

async function respondWithMock(response) {
await sleep(response.delay);
return new Response(response.body, response);
return mockedResponse;
}
Loading

0 comments on commit cb7d144

Please sign in to comment.