Skip to content

Commit

Permalink
feat(ct): expose msw fixture that accepts msw-style request handlers
Browse files Browse the repository at this point in the history
Implemented on top of `context.route()`.
  • Loading branch information
dgozman committed Jun 5, 2024
1 parent 384eed6 commit d05dae8
Show file tree
Hide file tree
Showing 7 changed files with 415 additions and 4 deletions.
39 changes: 39 additions & 0 deletions docs/src/test-components-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,45 @@ test('update', async ({ mount }) => {
</Tabs>
### Handling network requests
Playwright provides [`method: Page.route`] method to route your network requests. See the [network mocking guide](./mock.md) for more details.
### MSW support
If you are using the [MSW library](https://mswjs.io/) to handle network requests during development or testing, try the **experimental fixture** `msw` and give us feedback. With this fixture, you can reuse request handlers between development and tests.
```ts
import { handlers } from '@src/mocks/handlers';

test.beforeEach(async ({ msw }) => {
// install common handlers before each test
await msw.use(...handlers);
})

test('example test', async ({ mount }) => {
// test as usual, your handlers are active
// ...
});
```
You can also introduce test-specific handlers.
```ts
import { http, HttpResponse } from 'msw';

test('example test', async ({ mount, msw }) => {
await msw.use(
http.get('/data', async ({ request }) => {
return HttpResponse.json({ value: 'mocked' });
}),
);

// test as usual, your handler is active
// ...
});
```
## Frequently asked questions
### What's the difference between `@playwright/test` and `@playwright/experimental-ct-{react,svelte,vue,solid}`?
Expand Down
11 changes: 10 additions & 1 deletion packages/playwright-ct-core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import type {
} from 'playwright/test';
import type { InlineConfig } from 'vite';

//@ts-ignore This will be "any" if msw is not installed.
import type { RequestHandler } from 'msw';

export type PlaywrightTestConfig<T = {}, W = {}> = Omit<BasePlaywrightTestConfig<T, W>, 'use'> & {
use?: BasePlaywrightTestConfig<T, W>['use'] & {
ctPort?: number;
Expand All @@ -33,8 +36,14 @@ export type PlaywrightTestConfig<T = {}, W = {}> = Omit<BasePlaywrightTestConfig
};
};

type CommonComponentFixtures = {
msw: {
use(...handlers: RequestHandler[]): Promise<void>;
},
};

export type TestType<ComponentFixtures> = BaseTestType<
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures,
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & CommonComponentFixtures,
PlaywrightWorkerArgs & PlaywrightWorkerOptions
>;

Expand Down
12 changes: 9 additions & 3 deletions packages/playwright-ct-core/src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { Component, JsxComponent, MountOptions, ObjectComponentOptions } fr
import type { ContextReuseMode, FullConfigInternal } from '../../playwright/src/common/config';
import type { ImportRef } from './injected/importRegistry';
import { wrapObject } from './injected/serializers';
import { type MSW, MSWImpl } from './msw';

let boundCallbacksForMount: Function[] = [];

Expand All @@ -29,8 +30,9 @@ interface MountResult extends Locator {

type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
mount: (component: any, options: any) => Promise<MountResult>;
msw: MSW;
};
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } };
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions;
type BaseTestFixtures = {
_contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>,
_optionContextReuseMode: ContextReuseMode
Expand All @@ -42,8 +44,6 @@ export const fixtures: Fixtures<TestFixtures, WorkerFixtures, BaseTestFixtures>

serviceWorkers: 'block',

_ctWorker: [{ context: undefined, hash: '' }, { scope: 'worker' }],

page: async ({ page }, use, info) => {
if (!((info as any)._configInternal as FullConfigInternal).defineConfigWasUsed)
throw new Error('Component testing requires the use of the defineConfig() in your playwright-ct.config.{ts,js}: https://aka.ms/playwright/ct-define-config');
Expand Down Expand Up @@ -78,6 +78,12 @@ export const fixtures: Fixtures<TestFixtures, WorkerFixtures, BaseTestFixtures>
});
boundCallbacksForMount = [];
},

msw: async ({ context, baseURL }, use) => {
const msw = new MSWImpl(context, baseURL);
await use(msw);
await msw.dispose();
},
};

function isJsxComponent(component: any): component is JsxComponent {
Expand Down
164 changes: 164 additions & 0 deletions packages/playwright-ct-core/src/msw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type * as playwright from 'playwright/test';

interface RequestHandler {
run(args: { request: Request, resolutionContext: { baseUrl?: string }, requestId: string }): Promise<{ response: Response } | undefined>;
}

export interface MSW {
use(...handlers: RequestHandler[]): Promise<void>;
}

let lastRequestId = 0;
let fetchOverrideCounter = 0;
const currentlyInterceptingInContexts = new Map<playwright.BrowserContext, number>();
const originalFetch = globalThis.fetch;

async function executeRequestHandlers(request: Request, handlers: RequestHandler[], baseUrl: string | undefined): Promise<Response | undefined> {
const requestId = String(++lastRequestId);
const resolutionContext = { baseUrl };
for (const handler of handlers) {
const result = await handler.run({ request, requestId, resolutionContext });
if (result?.response)
return result.response;
}
}

async function globalFetch(...args: Parameters<typeof globalThis.fetch>) {
if (args[0] && args[0] instanceof Request) {
const request = args[0];
if (request.headers.get('x-msw-intention') === 'bypass') {
const cookieHeaders = await Promise.all([...currentlyInterceptingInContexts.keys()].map(async context => {
const cookies = await context.cookies(request.url);
if (!cookies.length)
return undefined;
return cookies.map(c => `${c.name}=${c.value}`).join('; ');
}));

if (!cookieHeaders.length)
throw new Error(`Cannot call fetch(bypass()) outside of a request handler`);

if (cookieHeaders.some(h => h !== cookieHeaders[0]))
throw new Error(`Cannot call fetch(bypass()) while concurrently handling multiple requests from different browser contexts`);

const headers = new Headers(request.headers);
headers.set('cookie', cookieHeaders[0]!);
headers.delete('x-msw-intention');
args[0] = new Request(request.clone(), { headers });
}
}
return originalFetch(...args);
}

export class MSWImpl implements MSW {
private _context: playwright.BrowserContext;
private _handlers: RequestHandler[] = [];
private _routeHandler: (route: playwright.Route) => Promise<void>;
private _attached = false;

constructor(context: playwright.BrowserContext, baseURL: string | undefined) {
this._context = context;

this._routeHandler = async route => {
if (route.request().isNavigationRequest()) {
await route.fallback();
return;
}

const request = route.request();
const headersArray = await request.headersArray();
const headers = new Headers();
for (const { name, value } of headersArray)
headers.append(name, value);

const buffer = request.postDataBuffer();
const body = buffer?.byteLength ? new Int8Array(buffer.buffer, buffer.byteOffset, buffer.length) : undefined;

const newRequest = new Request(request.url(), {
body: body,
headers: headers,
method: request.method(),
referrer: headersArray.find(h => h.name.toLowerCase() === 'referer')?.value,
});

currentlyInterceptingInContexts.set(context, 1 + (currentlyInterceptingInContexts.get(context) || 0));
const response = await executeRequestHandlers(newRequest, this._handlers, baseURL).finally(() => {
const value = currentlyInterceptingInContexts.get(context)! - 1;
if (value)
currentlyInterceptingInContexts.set(context, value);
else
currentlyInterceptingInContexts.delete(context);
});

if (!response) {
await route.fallback();
return;
}

if (response.status === 302 && response.headers.get('x-msw-intention') === 'passthrough') {
await route.continue();
return;
}

if (response.type === 'error') {
await route.abort();
return;
}

const responseHeaders: Record<string, string> = {};
for (const [name, value] of response.headers.entries()) {
if (responseHeaders[name])
responseHeaders[name] = responseHeaders[name] + (name.toLowerCase() === 'set-cookie' ? '\n' : ', ') + value;
else
responseHeaders[name] = value;
}
await route.fulfill({
status: response.status,
body: Buffer.from(await response.arrayBuffer()),
headers: responseHeaders,
});
};
}

async use(...handlers: RequestHandler[]) {
this._handlers = handlers.concat(this._handlers);
await this._update();
}

async dispose() {
this._handlers = [];
await this._update();
}

private async _update() {
if (this._handlers.length && !this._attached) {
await this._context.route('**/*', this._routeHandler);
if (!fetchOverrideCounter)
globalThis.fetch = globalFetch;
++fetchOverrideCounter;
this._attached = true;
}
if (!this._handlers.length && this._attached) {
await this._context.unroute('**/*', this._routeHandler);
this._attached = false;
--fetchOverrideCounter;
if (!fetchOverrideCounter)
globalThis.fetch = originalFetch;
}
}
}
1 change: 1 addition & 0 deletions tests/components/ct-react-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^4.2.1",
"msw": "^2.3.0",
"typescript": "^5.2.2",
"vite": "^5.2.8"
}
Expand Down
32 changes: 32 additions & 0 deletions tests/components/ct-react-vite/src/components/Fetcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useEffect, useState } from "react"

export default function Fetcher() {
const [data, setData] = useState<{ name: string }>({ name: '<none>' });
const [fetched, setFetched] = useState(false);

useEffect(() => {
const doFetch = async () => {
try {
const response = await fetch('/data.json');
setData(await response.json());
} catch {
setData({ name: '<error>' });
}
setFetched(true);
}

if (!fetched)
doFetch();
}, [fetched, setFetched, setData]);

return <div>
<div data-testId='name'>{data.name}</div>
<button onClick={() => {
setFetched(false);
setData({ name: '<none>' });
}}>Reset</button>
<button onClick={() => {
fetch('/post', { method: 'POST', body: 'hello from the page' });
}}>Post it</button>
</div>;
}
Loading

0 comments on commit d05dae8

Please sign in to comment.