diff --git a/docs/src/test-components-js.md b/docs/src/test-components-js.md index 7d733230e8..87cf263711 100644 --- a/docs/src/test-components-js.md +++ b/docs/src/test-components-js.md @@ -724,6 +724,76 @@ test('update', async ({ mount }) => { +### 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}`? diff --git a/packages/playwright-ct-core/index.d.ts b/packages/playwright-ct-core/index.d.ts index 2474bb792b..b397169b74 100644 --- a/packages/playwright-ct-core/index.d.ts +++ b/packages/playwright-ct-core/index.d.ts @@ -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 = Omit; +} + +export interface RouteFixture { + (...args: Parameters): Promise; + (handlers: RequestHandler[]): Promise; + (handler: RequestHandler): Promise; +} + export type TestType = BaseTestType< - PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures, + PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & { route: RouteFixture }, PlaywrightWorkerArgs & PlaywrightWorkerOptions >; diff --git a/packages/playwright-ct-core/src/mount.ts b/packages/playwright-ct-core/src/mount.ts index e743a6aaa1..3fa56a3a5b 100644 --- a/packages/playwright-ct-core/src/mount.ts +++ b/packages/playwright-ct-core/src/mount.ts @@ -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; + route: RouteFixture; }; -type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } }; +type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions; type BaseTestFixtures = { _contextFactory: (options?: BrowserContextOptions) => Promise, _optionContextReuseMode: ContextReuseMode @@ -42,8 +45,6 @@ export const fixtures: Fixtures 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 }); 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 { diff --git a/packages/playwright-ct-core/src/route.ts b/packages/playwright-ct-core/src/route.ts new file mode 100644 index 0000000000..92aca8be30 --- /dev/null +++ b/packages/playwright-ct-core/src/route.ts @@ -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; + +let lastRequestId = 0; +let fetchOverrideCounter = 0; +const currentlyInterceptingInContexts = new Map(); +const originalFetch = globalThis.fetch; + +async function executeRequestHandlers(request: Request, handlers: RequestHandler[], baseUrl: string | undefined): Promise { + 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) { + 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; + 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 = {}; + 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; + } + } +} diff --git a/tests/components/ct-react-vite/package.json b/tests/components/ct-react-vite/package.json index 341661f85f..2379d26af2 100644 --- a/tests/components/ct-react-vite/package.json +++ b/tests/components/ct-react-vite/package.json @@ -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" } diff --git a/tests/components/ct-react-vite/src/components/Fetcher.tsx b/tests/components/ct-react-vite/src/components/Fetcher.tsx new file mode 100644 index 0000000000..5c489ec8bb --- /dev/null +++ b/tests/components/ct-react-vite/src/components/Fetcher.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react" + +export default function Fetcher() { + const [data, setData] = useState<{ name: string }>({ name: '' }); + const [fetched, setFetched] = useState(false); + + useEffect(() => { + const doFetch = async () => { + try { + const response = await fetch('/data.json'); + setData(await response.json()); + } catch { + setData({ name: '' }); + } + setFetched(true); + } + + if (!fetched) + doFetch(); + }, [fetched, setFetched, setData]); + + return
+
{data.name}
+ + +
; +} diff --git a/tests/components/ct-react-vite/tests/route.spec.tsx b/tests/components/ct-react-vite/tests/route.spec.tsx index 657b0a7007..62a8680a1b 100644 --- a/tests/components/ct-react-vite/tests/route.spec.tsx +++ b/tests/components/ct-react-vite/tests/route.spec.tsx @@ -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(f => respond = f); + + let postReceived: ((body: string) => void) = () => {}; + const postBody = new Promise(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(); + await expect(component.getByTestId('name')).toHaveText(''); + + 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: '' }) }); + }); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + + 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: '' }) }); + }); + + await route( + http.get('/data.json', async () => { + return passthrough(); + }), + ); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + }); + + test('should fallback when nothing is returned', async ({ page, mount, route }) => { + await route('**/data.json', async route => { + await route.fulfill({ body: JSON.stringify({ name: '' }) }); + }); + + let called = false; + await route( + http.get('/data.json', async () => { + called = true; + }), + ); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + 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: `` }) }); + }); + + await route( + http.get('/data.json', async ({ request }) => { + return await fetch(bypass(request)); + }), + ); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + }); + + 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: '' })); + }); + await new Promise(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: `` }) }); + }); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + + 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(''); + + 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: `
original
`, contentType: 'text/html' }); + }); + + await route( + http.get('/newpage', async ({ request }) => { + return new Response(`
intercepted
`, { + headers: new Headers({ 'Content-Type': 'text/html' }), + }); + }), + ); + + await mount(
); + 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`); + }); + +}); +