Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ct): expose msw fixture that accepts msw-style request handlers #31154

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/src/test-components-js.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
---

Check warning on line 1 in docs/src/test-components-js.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Missing semicolon. 4 | // install common handlers before each test 5 | await msw.use(...handlers); > 6 | }) | ^ 7 | 8 | test('example test', async ({ mount }) => { 9 | // test as usual, your handlers are active Unable to lint: 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 // ... });

Check warning on line 1 in docs/src/test-components-js.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Expected indentation of 6 spaces but found 4. 3 | test('example test', async ({ mount, msw }) => { 4 | await msw.use( > 5 | http.get('/data', async ({ request }) => { | ^ 6 | return HttpResponse.json({ value: 'mocked' }); 7 | }), 8 | ); Unable to lint: 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 // ... });
id: test-components
title: "Components (experimental)"
---
Expand Down Expand Up @@ -721,6 +721,45 @@
</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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to actually import { RequestHandler } from '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>) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you add msw as a dependency, it exposes the getResponse function that you can reuse here. Less logic duplication.

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
Loading