feat(ct): experimental route fixture (#31554)

This fixture accepts the same arguments as `context.route()`, but also
supports request handlers compatible with msw syntax.
This commit is contained in:
Dmitry Gozman 2024-07-06 09:35:20 -07:00 committed by GitHub
parent b2bda9fce2
commit 369a1eca48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 469 additions and 4 deletions

View File

@ -724,6 +724,76 @@ test('update', async ({ mount }) => {
</Tabs>
### Handling network requests
Playwright provides a `route` fixture to intercept and handle network requests.
```ts
test.beforeEach(async ({ route }) => {
// install common routes before each test
await route('*/**/api/v1/fruits', async route => {
const json = [{ name: 'Strawberry', id: 21 }];
await route.fulfill({ json });
});
});
test('example test', async ({ mount }) => {
// test as usual, your routes are active
// ...
});
```
You can also introduce test-specific routes.
```ts
import { http, HttpResponse } from 'msw';
test('example test', async ({ mount, route }) => {
await route('*/**/api/v1/fruits', async route => {
const json = [{ name: 'fruit for this single test', id: 42 }];
await route.fulfill({ json });
});
// test as usual, your route is active
// ...
});
```
The `route` fixture works in the same way as [`method: Page.route`]. See the [network mocking guide](./mock.md) for more details.
**Re-using MSW handlers**
If you are using the [MSW library](https://mswjs.io/) to handle network requests during development or testing, you can pass them directly to the `route` fixture.
```ts
import { handlers } from '@src/mocks/handlers';
test.beforeEach(async ({ route }) => {
// install common handlers before each test
await route(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, route }) => {
await route(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}`?

View File

@ -21,6 +21,7 @@ import type {
PlaywrightTestOptions,
PlaywrightWorkerArgs,
PlaywrightWorkerOptions,
BrowserContext,
} from 'playwright/test';
import type { InlineConfig } from 'vite';
@ -33,8 +34,18 @@ export type PlaywrightTestConfig<T = {}, W = {}> = Omit<BasePlaywrightTestConfig
};
};
interface RequestHandler {
run(args: { request: Request, requestId?: string, resolutionContext?: { baseUrl?: string } }): Promise<{ response?: Response } | null>;
}
export interface RouteFixture {
(...args: Parameters<BrowserContext['route']>): Promise<void>;
(handlers: RequestHandler[]): Promise<void>;
(handler: RequestHandler): Promise<void>;
}
export type TestType<ComponentFixtures> = BaseTestType<
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures,
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & { route: RouteFixture },
PlaywrightWorkerArgs & PlaywrightWorkerOptions
>;

View File

@ -19,6 +19,8 @@ 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 { Router } from './route';
import type { RouteFixture } from '../index';
let boundCallbacksForMount: Function[] = [];
@ -29,8 +31,9 @@ interface MountResult extends Locator {
type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
mount: (component: any, options: any) => Promise<MountResult>;
route: RouteFixture;
};
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } };
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions;
type BaseTestFixtures = {
_contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>,
_optionContextReuseMode: ContextReuseMode
@ -42,8 +45,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');
@ -78,6 +79,12 @@ export const fixtures: Fixtures<TestFixtures, WorkerFixtures, BaseTestFixtures>
});
boundCallbacksForMount = [];
},
route: async ({ context, baseURL }, use) => {
const router = new Router(context, baseURL);
await use((...args) => router.handle(...args));
await router.dispose();
},
};
function isJsxComponent(component: any): component is JsxComponent {

View File

@ -0,0 +1,181 @@
/**
* 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, requestId?: string, resolutionContext?: { baseUrl?: string } }): Promise<{ response?: Response } | null>;
}
type RouteArgs = Parameters<playwright.BrowserContext['route']>;
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 Router {
private _context: playwright.BrowserContext;
private _requestHandlers: RequestHandler[] = [];
private _requestHandlersRoute: (route: playwright.Route) => Promise<void>;
private _requestHandlersActive = false;
private _routes: RouteArgs[] = [];
constructor(context: playwright.BrowserContext, baseURL: string | undefined) {
this._context = context;
this._requestHandlersRoute = 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._requestHandlers, 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 handle(...args: any[]) {
// Multiple RequestHandlers.
if (Array.isArray(args[0])) {
const handlers = args[0] as RequestHandler[];
this._requestHandlers = handlers.concat(this._requestHandlers);
await this._updateRequestHandlersRoute();
return;
}
// Single RequestHandler.
if (args.length === 1 && typeof args[0] === 'object') {
const handlers = [args[0] as RequestHandler];
this._requestHandlers = handlers.concat(this._requestHandlers);
await this._updateRequestHandlersRoute();
return;
}
// Arguments of BrowserContext.route(url, handler, options?).
const routeArgs = args as RouteArgs;
this._routes.push(routeArgs);
await this._context.route(...routeArgs);
}
async dispose() {
this._requestHandlers = [];
await this._updateRequestHandlersRoute();
for (const route of this._routes)
await this._context.unroute(route[0], route[1]);
}
private async _updateRequestHandlersRoute() {
if (this._requestHandlers.length && !this._requestHandlersActive) {
await this._context.route('**/*', this._requestHandlersRoute);
if (!fetchOverrideCounter)
globalThis.fetch = globalFetch;
++fetchOverrideCounter;
this._requestHandlersActive = true;
}
if (!this._requestHandlers.length && this._requestHandlersActive) {
await this._context.unroute('**/*', this._requestHandlersRoute);
this._requestHandlersActive = false;
--fetchOverrideCounter;
if (!fetchOverrideCounter)
globalThis.fetch = originalFetch;
}
}
}

View File

@ -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"
}

View File

@ -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>;
}

View File

@ -1,5 +1,9 @@
import { test, expect } from '@playwright/experimental-ct-react';
import TitleWithFont from '@/components/TitleWithFont';
import Fetcher from '@/components/Fetcher';
import { http, HttpResponse, passthrough, bypass } from 'msw';
import httpServer from 'http';
import type net from 'net';
test('should load font without routes', async ({ mount, page }) => {
const promise = page.waitForEvent('requestfinished', request => request.url().includes('iconfont'));
@ -20,3 +24,162 @@ test('should load font with routes', async ({ mount, page }) => {
const body = await response!.body();
expect(body.length).toBe(2656);
});
test.describe('request handlers', () => {
test('should handle requests', async ({ page, mount, route }) => {
let respond: (() => void) = () => {};
const promise = new Promise<void>(f => respond = f);
let postReceived: ((body: string) => void) = () => {};
const postBody = new Promise<string>(f => postReceived = f);
await route([
http.get('/data.json', async () => {
await promise;
return HttpResponse.json({ name: 'John Doe' });
}),
http.post('/post', async ({ request }) => {
postReceived(await request.text());
return HttpResponse.text('ok');
}),
]);
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<none>');
respond();
await expect(component.getByTestId('name')).toHaveText('John Doe');
await component.getByRole('button', { name: 'Post it' }).click();
expect(await postBody).toBe('hello from the page');
});
test('should add dynamically', async ({ page, mount, route }) => {
await route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
});
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<original>');
await route(
http.get('/data.json', async () => {
return HttpResponse.json({ name: 'John Doe' });
}),
);
await component.getByRole('button', { name: 'Reset' }).click();
await expect(component.getByTestId('name')).toHaveText('John Doe');
});
test('should passthrough', async ({ page, mount, route }) => {
await route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
});
await route(
http.get('/data.json', async () => {
return passthrough();
}),
);
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<error>');
});
test('should fallback when nothing is returned', async ({ page, mount, route }) => {
await route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
});
let called = false;
await route(
http.get('/data.json', async () => {
called = true;
}),
);
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<original>');
expect(called).toBe(true);
});
test('should bypass(request)', async ({ page, mount, route }) => {
await route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: `<original>` }) });
});
await route(
http.get('/data.json', async ({ request }) => {
return await fetch(bypass(request));
}),
);
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<error>');
});
test('should bypass(url) and get cookies', async ({ page, mount, route, browserName }) => {
let cookie = '';
const server = new httpServer.Server();
server.on('request', (req, res) => {
cookie = req.headers['cookie']!;
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ name: '<server>' }));
});
await new Promise<void>(f => server.listen(0, f));
const port = (server.address() as net.AddressInfo).port;
await route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: `<original>` }) });
});
const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<original>');
await page.evaluate(() => document.cookie = 'foo=bar');
await route(
http.get('/data.json', async ({ request }) => {
if (browserName !== 'webkit') {
// WebKit does not have cookies while intercepting.
expect(request.headers.get('cookie')).toBe('foo=bar');
}
return await fetch(bypass(`http://localhost:${port}`));
}),
);
await component.getByRole('button', { name: 'Reset' }).click();
await expect(component.getByTestId('name')).toHaveText('<server>');
expect(cookie).toBe('foo=bar');
await new Promise(f => server.close(f));
});
test('should ignore navigation requests', async ({ page, mount, route }) => {
await route('**/newpage', async route => {
await route.fulfill({ body: `<div>original</div>`, contentType: 'text/html' });
});
await route(
http.get('/newpage', async ({ request }) => {
return new Response(`<div>intercepted</div>`, {
headers: new Headers({ 'Content-Type': 'text/html' }),
});
}),
);
await mount(<div />);
await page.goto('/newpage');
await expect(page.locator('div')).toHaveText('original');
});
test('should throw when calling fetch(bypass) outside of a handler', async ({ page, route, baseURL }) => {
await route(
http.get('/data.json', async () => {
}),
);
const error = await fetch(bypass(baseURL + '/hello')).catch(e => e);
expect(error.message).toContain(`Cannot call fetch(bypass()) outside of a request handler`);
});
});