feat(test runner): server side mocking

fix linter

revert unneeded change
This commit is contained in:
Simon Knott 2025-01-28 16:32:23 +01:00
parent 63f96efbe0
commit 156918e2d6
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
22 changed files with 1249 additions and 95 deletions

View file

@ -554,3 +554,179 @@ await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
```
For more details, see [WebSocketRoute].
## Mock Server
* langs: js
By default, Playwright only has access to the network traffic made by the browser.
To mock and intercept traffic made by the application server, use Playwright's **experimental** mocking proxy. Note this feature is **experimental** and subject to change.
The mocking proxy is a HTTP proxy server that's connected to the currently running test.
If you send it a request, it will apply the network routes configured via `page.route` and `context.route`, reusing your existing browser routes.
To get started, enable the `mockingProxy` option in your Playwright config:
```ts
export default defineConfig({
use: { mockingProxy: true }
});
```
Playwright will now inject the proxy URL into all browser requests under the `x-playwright-proxy` header.
On your server, read the URL in this header and prepend it to all outgoing traffic you want to intercept:
```js
const headers = getCurrentRequestHeaders(); // this looks different for each application
const proxyURL = decodeURIComponent(headers.get('x-playwright-proxy') ?? '');
await fetch(proxyURL + 'https://api.example.com/users');
```
Prepending the URL will direct the request through the proxy. You can now intercept it with `context.route` and `page.route`, just like browser requests:
```ts
// shopping-cart.spec.ts
import { test, expect } from '@playwright/test';
test('checkout applies customer loyalty bonus points', async ({ page }) => {
await page.route('https://users.internal.example.com/loyalty/balance*', (route, request) => {
await route.fulfill({ json: { userId: 'jane@doe.com', balance: 100 } });
});
await page.goto('http://localhost:3000/checkout');
await expect(page.getByRole('list')).toMatchAriaSnapshot(`
- list "Cart":
- listitem: Super Duper Hammer
- listitem: Nails
- listitem: 16mm Birch Plywood
- text: "Price after applying 10$ loyalty discount: 79.99$"
- button "Buy now"
`);
});
```
Now, prepending the proxy URL manually can be cumbersome. If your HTTP client supports it, consider updating your client baseURL ...
```js
import { axios } from 'axios';
const api = axios.create({
baseURL: proxyURL + 'https://jsonplaceholder.typicode.com',
});
```
... or setting up a global interceptor:
```js
import { axios } from 'axios';
axios.interceptors.request.use(async config => {
config.baseURL = proxyURL + (config.baseURL ?? '/');
return config;
});
```
```js
import { setGlobalDispatcher, getGlobalDispatcher } from 'undici';
const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
opts.path = opts.origin + opts.path;
opts.origin = proxyURL;
return dispatch(opts, handler);
});
setGlobalDispatcher(proxyingDispatcher); // this will also apply to global fetch
```
:::note
Note that this style of proxying, where the proxy URL is prended to the request URL, does *not* use [`CONNECT`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT), which is the common way of establishing a proxy connection.
This is because for HTTPS requests, a `CONNECT` proxy does not have access to the proxied traffic. That's great behaviour for a production proxy, but counteracts network interception!
:::
### Recipes
* langs: js
#### Next.js
* langs: js
Monkey-patch `globalThis.fetch` in your `instrumentation.ts` file:
```ts
// instrumentation.ts
import { headers } from 'next/headers';
export function register() {
if (process.env.NODE_ENV === 'test') {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const proxy = (await headers()).get('x-playwright-proxy');
if (!proxy)
return originalFetch(input, init);
const request = new Request(input, init);
return originalFetch(decodeURIComponent(proxy) + request.url, request);
};
}
}
```
#### Remix
* langs: js
Monkey-patch `globalThis.fetch` in your `entry.server.ts` file, and use `AsyncLocalStorage` to make current request headers available:
```ts
import { setGlobalDispatcher, getGlobalDispatcher } from 'undici';
import { AsyncLocalStorage } from 'node:async_hooks';
const headersStore = new AsyncLocalStorage<Headers>();
if (process.env.NODE_ENV === 'test') {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const proxy = headersStore.getStore()?.get('x-playwright-proxy');
if (!proxy)
return originalFetch(input, init);
const request = new Request(input, init);
return originalFetch(decodeURIComponent(proxy) + request.url, request);
};
}
export default function handleRequest(request: Request, /* ... */) {
return headersStore.run(request.headers, () => {
// ...
return handleBrowserRequest(request, /* ... */);
});
}
```
#### Angular
* langs: js
Configure your `HttpClient` with an [interceptor](https://angular.dev/guide/http/setup#withinterceptors):
```ts
// app.config.server.ts
import { inject, REQUEST } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
const serverConfig = {
providers: [
/* ... */
provideHttpClient(
/* ... */
withInterceptors([
(req, next) => {
const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy');
if (proxy)
req = req.clone({ url: decodeURIComponent(proxy) + req.url })
return next(req);
},
]),
)
]
};
/* ... */
```

View file

@ -676,3 +676,19 @@ export default defineConfig({
},
});
```
## property: TestOptions.mockingProxy
* since: v1.51
- type: <[boolean]> Enables the mocking proxy. Playwright will inject the proxy URL into all outgoing requests under the `x-playwright-proxy` header.
**Usage**
```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
mockingProxy: true
},
});
```

View file

@ -29,7 +29,8 @@ import { Events } from './events';
import { TimeoutSettings } from '../common/timeoutSettings';
import { Waiter } from './waiter';
import type { Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions } from './types';
import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded } from '../utils';
import type { RegisteredListener } from '../utils';
import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded, eventsHelper } from '../utils';
import type * as api from '../../types/types';
import type * as structs from '../../types/structs';
import { CDPSession } from './cdpSession';
@ -44,6 +45,7 @@ import { Dialog } from './dialog';
import { WebError } from './webError';
import { TargetClosedError, parseError } from './errors';
import { Clock } from './clock';
import type { MockingProxy } from './mockingProxy';
export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel> implements api.BrowserContext {
_pages = new Set<Page>();
@ -68,6 +70,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
_closeWasCalled = false;
private _closeReason: string | undefined;
private _harRouters: HarRouter[] = [];
private _registeredListeners: RegisteredListener[] = [];
_mockingProxy?: MockingProxy;
static from(context: channels.BrowserContextChannel): BrowserContext {
return (context as any)._object;
@ -90,7 +94,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding)));
this._channel.on('close', () => this._onClose());
this._channel.on('page', ({ page }) => this._onPage(Page.from(page)));
this._channel.on('route', ({ route }) => this._onRoute(network.Route.from(route)));
this._channel.on('route', params => {
const route = network.Route.from(params.route);
route._context = this.request;
this._onRoute(route);
});
this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute)));
this._channel.on('backgroundPage', ({ page }) => {
const backgroundPage = Page.from(page);
@ -157,9 +165,10 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this.tracing._tracesDir = browserOptions.tracesDir;
}
private _onPage(page: Page): void {
private async _onPage(page: Page): Promise<void>{
this._pages.add(page);
this.emit(Events.BrowserContext.Page, page);
await this._mockingProxy?.instrumentPage(page);
if (page._opener && !page._opener.isClosed())
page._opener.emit(Events.Page.Popup, page);
}
@ -198,7 +207,6 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
}
async _onRoute(route: network.Route) {
route._context = this;
const page = route.request()._safePage();
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
@ -238,6 +246,19 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await bindingCall.call(func);
}
async _subscribeToMockingProxy(mockingProxy: MockingProxy) {
if (this._mockingProxy)
throw new Error('Multiple mocking proxies are not supported');
this._mockingProxy = mockingProxy;
this._registeredListeners.push(
eventsHelper.addEventListener(this._mockingProxy, Events.MockingProxy.Route, (route: network.Route) => {
const page = route.request()._safePage()!;
page._onRoute(route);
}),
// TODO: should we also emit `request`, `response`, `requestFinished`, `requestFailed` events?
);
}
setDefaultNavigationTimeout(timeout: number | undefined) {
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
this._wrapApiCall(async () => {
@ -400,6 +421,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
private async _updateInterceptionPatterns() {
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes);
await this._channel.setNetworkInterceptionPatterns({ patterns });
await this._mockingProxy?.setInterceptionPatterns({ patterns });
}
private async _updateWebSocketInterceptionPatterns() {
@ -457,6 +479,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this._disposeHarRouters();
this.tracing._resetStackCounter();
this.emit(Events.BrowserContext.Close, this);
eventsHelper.removeEventListeners(this._registeredListeners);
}
async [Symbol.asyncDispose]() {

View file

@ -45,6 +45,7 @@ import { findValidator, ValidationError, type ValidatorContext } from '../protoc
import { createInstrumentation } from './clientInstrumentation';
import type { ClientInstrumentation } from './clientInstrumentation';
import { formatCallLog, rewriteErrorMessage, zones } from '../utils';
import { MockingProxy } from './mockingProxy';
class Root extends ChannelOwner<channels.RootChannel> {
constructor(connection: Connection) {
@ -279,6 +280,9 @@ export class Connection extends EventEmitter {
if (!this._localUtils)
this._localUtils = result as LocalUtils;
break;
case 'MockingProxy':
result = new MockingProxy(parent, type, guid, initializer);
break;
case 'Page':
result = new Page(parent, type, guid, initializer);
break;

View file

@ -94,4 +94,8 @@ export const Events = {
Console: 'console',
Window: 'window',
},
MockingProxy: {
Route: 'route',
},
};

View file

@ -0,0 +1,76 @@
/**
* 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 * as network from './network';
import type * as channels from '@protocol/channels';
import { ChannelOwner } from './channelOwner';
import { APIRequestContext } from './fetch';
import { Events } from './events';
import { assert } from '../utils';
import type { Page } from './page';
export class MockingProxy extends ChannelOwner<channels.MockingProxyChannel> {
private _pages = new Map<string, Page>();
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.MockingProxyInitializer) {
super(parent, type, guid, initializer);
const requestContext = APIRequestContext.from(initializer.requestContext);
this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => {
const route = network.Route.from(params.route);
route._context = requestContext;
this.emit(Events.MockingProxy.Route, route);
});
this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => {
const page = this._pages.get(params.correlation);
assert(page);
const request = network.Request.from(params.request);
request._page = page;
});
this._channel.on('requestFailed', async (params: channels.MockingProxyRequestFailedEvent) => {
const request = network.Request.from(params.request);
request._failureText = params.failureText ?? null;
request._setResponseEndTiming(params.responseEndTiming);
});
this._channel.on('requestFinished', async (params: channels.MockingProxyRequestFinishedEvent) => {
const { responseEndTiming } = params;
const request = network.Request.from(params.request);
const response = network.Response.fromNullable(params.response);
request._setResponseEndTiming(responseEndTiming);
response?._finishedPromise.resolve(null);
});
this._channel.on('response', async (params: channels.MockingProxyResponseEvent) => {
// no-op
});
}
async setInterceptionPatterns(params: channels.MockingProxySetInterceptionPatternsParams) {
await this._channel.setInterceptionPatterns(params);
}
async instrumentPage(page: Page) {
const correlation = page._guid.split('@')[1];
this._pages.set(correlation, page);
const proxyUrl = `http://localhost:${this._initializer.port}/pw_meta:${correlation}/`;
await page.setExtraHTTPHeaders({
'x-playwright-proxy': encodeURIComponent(proxyUrl)
});
}
}

View file

@ -30,9 +30,9 @@ import type { Page } from './page';
import { Waiter } from './waiter';
import type * as api from '../../types/types';
import type { HeadersArray } from '../common/types';
import type { APIRequestContext } from './fetch';
import { APIResponse } from './fetch';
import type { Serializable } from '../../types/structs';
import type { BrowserContext } from './browserContext';
import { isTargetClosedError } from './errors';
export type NetworkCookie = {
@ -86,6 +86,7 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
private _actualHeadersPromise: Promise<RawHeaders> | undefined;
_timing: ResourceTiming;
private _fallbackOverrides: SerializedFallbackOverrides = {};
_page: Page | null = null;
static from(request: channels.RequestChannel): Request {
return (request as any)._object;
@ -216,7 +217,7 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
}
_safePage(): Page | null {
return Frame.fromNullable(this._initializer.frame)?._page || null;
return this._page ?? Frame.fromNullable(this._initializer.frame)?._page ?? null;
}
serviceWorker(): Worker | null {
@ -291,7 +292,7 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
export class Route extends ChannelOwner<channels.RouteChannel> implements api.Route {
private _handlingPromise: ManualPromise<boolean> | null = null;
_context!: BrowserContext;
_context!: APIRequestContext;
_didThrow: boolean = false;
static from(route: channels.RouteChannel): Route {
@ -339,7 +340,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
async fetch(options: FallbackOverrides & { maxRedirects?: number, maxRetries?: number, timeout?: number } = {}): Promise<APIResponse> {
return await this._wrapApiCall(async () => {
return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options });
return await this._context._innerFetch({ request: this.request(), data: options.postData, ...options });
});
}

View file

@ -138,7 +138,11 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
this._channel.on('frameAttached', ({ frame }) => this._onFrameAttached(Frame.from(frame)));
this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame)));
this._channel.on('locatorHandlerTriggered', ({ uid }) => this._onLocatorHandlerTriggered(uid));
this._channel.on('route', ({ route }) => this._onRoute(Route.from(route)));
this._channel.on('route', params => {
const route = Route.from(params.route);
route._context = this.context().request;
this._onRoute(route);
});
this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(WebSocketRoute.from(webSocketRoute)));
this._channel.on('video', ({ artifact }) => {
const artifactObject = Artifact.from(artifact);
@ -179,8 +183,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
this.emit(Events.Page.FrameDetached, frame);
}
private async _onRoute(route: Route) {
route._context = this.context();
async _onRoute(route: Route) {
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
// If the page was closed we stall all requests right away.

View file

@ -66,6 +66,7 @@ export const slowMoActions = new Set([
export const commandsWithTracingSnapshots = new Set([
'EventTarget.waitForEventInfo',
'MockingProxy.waitForEventInfo',
'BrowserContext.waitForEventInfo',
'Page.waitForEventInfo',
'WebSocket.waitForEventInfo',

View file

@ -226,6 +226,29 @@ scheme.APIResponse = tObject({
headers: tArray(tType('NameValue')),
});
scheme.LifecycleEvent = tEnum(['load', 'domcontentloaded', 'networkidle', 'commit']);
scheme.EventTargetInitializer = tOptional(tObject({}));
scheme.EventTargetWaitForEventInfoParams = tObject({
info: tObject({
waitId: tString,
phase: tEnum(['before', 'after', 'log']),
event: tOptional(tString),
message: tOptional(tString),
error: tOptional(tString),
}),
});
scheme.MockingProxyWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.BrowserContextWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.PageWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.WebSocketWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.ElectronApplicationWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.AndroidDeviceWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({}));
scheme.MockingProxyWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.BrowserContextWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.PageWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.WebSocketWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.LocalUtilsInitializer = tObject({
deviceDescriptors: tArray(tObject({
name: tString,
@ -313,6 +336,43 @@ scheme.LocalUtilsTraceDiscardedParams = tObject({
stacksId: tString,
});
scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({}));
scheme.LocalUtilsNewMockingProxyParams = tOptional(tObject({}));
scheme.LocalUtilsNewMockingProxyResult = tObject({
mockingProxy: tChannel(['MockingProxy']),
});
scheme.MockingProxyInitializer = tObject({
port: tNumber,
requestContext: tChannel(['APIRequestContext']),
});
scheme.MockingProxyRouteEvent = tObject({
route: tChannel(['Route']),
});
scheme.MockingProxyRequestEvent = tObject({
request: tChannel(['Request']),
correlation: tString,
});
scheme.MockingProxyRequestFailedEvent = tObject({
request: tChannel(['Request']),
failureText: tOptional(tString),
responseEndTiming: tNumber,
});
scheme.MockingProxyRequestFinishedEvent = tObject({
request: tChannel(['Request']),
response: tOptional(tChannel(['Response'])),
responseEndTiming: tNumber,
});
scheme.MockingProxyResponseEvent = tObject({
response: tChannel(['Response']),
page: tOptional(tChannel(['Page'])),
});
scheme.MockingProxySetInterceptionPatternsParams = tObject({
patterns: tArray(tObject({
glob: tOptional(tString),
regexSource: tOptional(tString),
regexFlags: tOptional(tString),
})),
});
scheme.MockingProxySetInterceptionPatternsResult = tOptional(tObject({}));
scheme.RootInitializer = tOptional(tObject({}));
scheme.RootInitializeParams = tObject({
sdkLanguage: tEnum(['javascript', 'python', 'java', 'csharp']),
@ -780,27 +840,6 @@ scheme.BrowserStopTracingParams = tOptional(tObject({}));
scheme.BrowserStopTracingResult = tObject({
artifact: tChannel(['Artifact']),
});
scheme.EventTargetInitializer = tOptional(tObject({}));
scheme.EventTargetWaitForEventInfoParams = tObject({
info: tObject({
waitId: tString,
phase: tEnum(['before', 'after', 'log']),
event: tOptional(tString),
message: tOptional(tString),
error: tOptional(tString),
}),
});
scheme.BrowserContextWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.PageWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.WebSocketWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.ElectronApplicationWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.AndroidDeviceWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams');
scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({}));
scheme.BrowserContextWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.PageWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.WebSocketWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult');
scheme.BrowserContextInitializer = tObject({
isChromium: tBoolean,
requestContext: tChannel(['APIRequestContext']),

View file

@ -44,7 +44,7 @@ import { Clock } from './clock';
import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import { RecorderApp } from './recorder/recorderApp';
export abstract class BrowserContext extends SdkObject {
export abstract class BrowserContext extends SdkObject implements network.RequestContext {
static Events = {
Console: 'console',
Close: 'close',

View file

@ -41,8 +41,12 @@ import type { Playwright } from '../playwright';
import { SdkObject } from '../../server/instrumentation';
import { serializeClientSideCallMetadata } from '../../utils';
import { deviceDescriptors as descriptors } from '../deviceDescriptors';
import type { APIRequestContext } from '../fetch';
import { GlobalAPIRequestContext } from '../fetch';
import { MockingProxy } from '../mockingProxy';
import { MockingProxyDispatcher } from './mockingProxyDispatcher';
export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
export class LocalUtilsDispatcher extends Dispatcher<SdkObject, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
_type_LocalUtils: boolean;
private _harBackends = new Map<string, HarBackend>();
private _stackSessions = new Map<string, {
@ -51,14 +55,21 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
tmpDir: string | undefined,
callStacks: channels.ClientSideCallMetadata[]
}>();
private _requestContext: APIRequestContext;
constructor(scope: RootDispatcher, playwright: Playwright) {
const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils');
const deviceDescriptors = Object.entries(descriptors)
.map(([name, descriptor]) => ({ name, descriptor }));
const requestContext = new GlobalAPIRequestContext(
playwright,
{} // TODO: this should probably respect _combinedContextOptions from test runner
);
super(scope, localUtils, 'LocalUtils', {
deviceDescriptors,
});
this._requestContext = requestContext;
this._type_LocalUtils = true;
}
@ -273,6 +284,12 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
await removeFolders([session.tmpDir]);
this._stackSessions.delete(stacksId!);
}
async newMockingProxy(params: channels.LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise<channels.LocalUtilsNewMockingProxyResult> {
const mockingProxy = new MockingProxy(this._object, this._requestContext);
await mockingProxy.start();
return { mockingProxy: MockingProxyDispatcher.from(this.parentScope(), mockingProxy) };
}
}
const redirectStatus = [301, 302, 303, 307, 308];
@ -295,7 +312,8 @@ class HarBackend {
redirectURL?: string,
status?: number,
headers?: HeadersArray,
body?: Buffer }> {
body?: Buffer
}> {
let entry;
try {
entry = await this._harFindResponse(url, method, headers, postData);

View file

@ -0,0 +1,69 @@
/**
* 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 { CallMetadata } from '@protocol/callMetadata';
import { MockingProxy } from '../mockingProxy';
import type { RootDispatcher } from './dispatcher';
import { Dispatcher, existingDispatcher } from './dispatcher';
import type * as channels from '@protocol/channels';
import { APIRequestContextDispatcher, RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
import type { Request, Route } from '../network';
import { urlMatches } from '../../utils/isomorphic/urlMatch';
export class MockingProxyDispatcher extends Dispatcher<MockingProxy, channels.MockingProxyChannel, RootDispatcher> implements channels.MockingProxyChannel {
_type_MockingProxy = true;
_type_EventTarget = true;
static from(scope: RootDispatcher, mockingProxy: MockingProxy): MockingProxyDispatcher {
return existingDispatcher<MockingProxyDispatcher>(mockingProxy) || new MockingProxyDispatcher(scope, mockingProxy);
}
private constructor(scope: RootDispatcher, mockingProxy: MockingProxy) {
super(scope, mockingProxy, 'MockingProxy', {
port: mockingProxy.port,
requestContext: APIRequestContextDispatcher.from(scope, mockingProxy.fetchRequest),
});
this.addObjectListener(MockingProxy.Events.Route, (route: Route) => {
const requestDispatcher = RequestDispatcher.from(this as any, route.request());
this._dispatchEvent('route', { route: RouteDispatcher.from(requestDispatcher, route) });
});
this.addObjectListener(MockingProxy.Events.Request, ({ request, correlation }: { request: Request, correlation: string }) => {
this._dispatchEvent('request', { request: RequestDispatcher.from(this as any, request), correlation });
});
this.addObjectListener(MockingProxy.Events.RequestFailed, (request: Request) => {
this._dispatchEvent('requestFailed', {
request: RequestDispatcher.from(this as any, request),
failureText: request._failureText ?? undefined,
responseEndTiming: request._responseEndTiming,
});
});
this.addObjectListener(MockingProxy.Events.RequestFinished, (request: Request) => {
this._dispatchEvent('requestFinished', {
request: RequestDispatcher.from(this as any, request),
response: ResponseDispatcher.fromNullable(this as any, request._existingResponse()),
responseEndTiming: request._responseEndTiming,
});
});
}
async setInterceptionPatterns(params: channels.MockingProxySetInterceptionPatternsParams, metadata?: CallMetadata): Promise<channels.MockingProxySetInterceptionPatternsResult> {
if (params.patterns.length === 0)
return this._object.setInterceptionPatterns(undefined);
const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
this._object.setInterceptionPatterns(url => urlMatchers.some(urlMatch => urlMatches(undefined, url, urlMatch)));
}
}

View file

@ -0,0 +1,264 @@
/**
* 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 http from 'http';
import https from 'https';
import url from 'url';
import type { APIRequestContext } from './fetch';
import { SdkObject } from './instrumentation';
import type { RequestContext, ResourceTiming, SecurityDetails } from './network';
import { Request, Response, Route } from './network';
import type { HeadersArray, } from './types';
import { HttpServer, ManualPromise, monotonicTime } from '../utils';
import { TLSSocket } from 'tls';
import type { AddressInfo } from 'net';
import { pipeline } from 'stream/promises';
import { Transform } from 'stream';
export class MockingProxy extends SdkObject implements RequestContext {
static Events = {
Request: 'request',
Response: 'response',
Route: 'route',
RequestFailed: 'requestfailed',
RequestFinished: 'requestfinished',
};
fetchRequest: APIRequestContext;
private _matches?: (url: string) => boolean;
private _httpServer = new WorkerHttpServer();
constructor(parent: SdkObject, requestContext: APIRequestContext) {
super(parent, 'MockingProxy');
this.fetchRequest = requestContext;
this._httpServer.routePrefix('/', (req, res) => {
this._proxy(req, res);
return true;
});
this._httpServer.server().on('connect', (req, socket, head) => {
// TODO: improve error message
socket.end('HTTP/1.1 405 Method Not Allowed\r\n\r\n');
});
}
async start(): Promise<void> {
await this._httpServer.start();
}
get port() {
return this._httpServer.port();
}
setInterceptionPatterns(matches?: (url: string) => boolean) {
this._matches = matches;
}
private async _proxy(req: http.IncomingMessage, res: http.ServerResponse) {
if (req.url?.startsWith('/'))
req.url = req.url.substring(1);
if (!req.url?.startsWith('pw_meta:')) {
res.statusCode = 400;
res.end('Playwright mocking proxy received invalid URL, must start with "pw_meta:"');
return;
}
const correlation = req.url.substring('pw_meta:'.length, req.url.indexOf('/'));
req.url = req.url.substring(req.url.indexOf('/') + 1);
// Java URL likes removing double slashes from the pathname.
if (req.url?.startsWith('http:/') && !req.url?.startsWith('http://'))
req.url = req.url.replace('http:/', 'http://');
if (req.url?.startsWith('https:/') && !req.url?.startsWith('https://'))
req.url = req.url.replace('https:/', 'https://');
delete req.headersDistinct.host;
const headers = headersArray(req);
const body = await collectBody(req);
const request = new Request(this, null, null, null, undefined, req.url!, '', req.method!, body, headers);
request.setRawRequestHeaders(headers);
this.emit(MockingProxy.Events.Request, { request, correlation });
const route = new Route(request, {
abort: async errorCode => {
req.destroy(errorCode ? new Error(errorCode) : undefined);
},
continue: async overrides => {
const proxyUrl = url.parse(overrides?.url ?? req.url!);
const httpLib = proxyUrl.protocol === 'https:' ? https : http;
const proxyHeaders = overrides?.headers ?? headers;
const proxyMethod = overrides?.method ?? req.method;
const proxyBody = overrides?.postData ?? body;
const startAt = monotonicTime();
let connectEnd: number | undefined;
let connectStart: number | undefined;
let dnsLookupAt: number | undefined;
let tlsHandshakeAt: number | undefined;
let socketBytesReadStart = 0;
return new Promise<void>(resolve => {
const proxyReq = httpLib.request({
...proxyUrl,
headers: headersArrayToOutgoingHeaders(proxyHeaders),
method: proxyMethod,
}, async proxyRes => {
const responseStart = monotonicTime();
const timings: ResourceTiming = {
startTime: startAt / 1000,
connectStart: connectStart ? (connectStart - startAt) : -1,
connectEnd: connectEnd ? (connectEnd - startAt) : -1,
domainLookupStart: -1,
domainLookupEnd: dnsLookupAt ? (dnsLookupAt - startAt) : -1,
requestStart: -1,
responseStart: (responseStart - startAt),
secureConnectionStart: tlsHandshakeAt ? (tlsHandshakeAt - startAt) : -1,
};
const socket = proxyRes.socket;
let securityDetails: SecurityDetails | undefined;
if (socket instanceof TLSSocket) {
const peerCertificate = socket.getPeerCertificate();
securityDetails = {
protocol: socket.getProtocol() ?? undefined,
subjectName: peerCertificate.subject.CN,
validFrom: new Date(peerCertificate.valid_from).getTime() / 1000,
validTo: new Date(peerCertificate.valid_to).getTime() / 1000,
issuer: peerCertificate.issuer.CN
};
}
const address = socket.address() as AddressInfo;
const responseBodyPromise = new ManualPromise<Buffer>();
const response = new Response(request, proxyRes.statusCode!, proxyRes.statusMessage!, headersArray(proxyRes), timings, () => responseBodyPromise, false, proxyRes.httpVersion);
response.setRawResponseHeaders(headersArray(proxyRes));
response._securityDetailsFinished(securityDetails);
response._serverAddrFinished({ ipAddress: address.family === 'IPv6' ? `[${address.address}]` : address.address, port: address.port });
this.emit(MockingProxy.Events.Response, response);
try {
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
const chunks: Buffer[] = [];
await pipeline(
proxyRes,
new Transform({
transform(chunk, encoding, callback) {
chunks.push(chunk);
callback(undefined, chunk);
},
}),
res
);
const body = Buffer.concat(chunks);
responseBodyPromise.resolve(body);
const transferSize = socket.bytesRead - socketBytesReadStart;
const encodedBodySize = body.byteLength;
response._requestFinished(monotonicTime() - startAt);
response.setTransferSize(transferSize);
response.setEncodedBodySize(encodedBodySize);
response.setResponseHeadersSize(transferSize - encodedBodySize);
this.emit(MockingProxy.Events.RequestFinished, request);
resolve();
} catch (error) {
request._setFailureText('' + error);
this.emit(MockingProxy.Events.RequestFailed, request);
resolve();
}
});
proxyReq.on('error', error => {
request._setFailureText('' + error);
this.emit(MockingProxy.Events.RequestFailed, request);
res.statusCode = 502;
res.end(resolve);
});
proxyReq.once('socket', socket => {
if (proxyReq.reusedSocket)
return;
socketBytesReadStart = socket.bytesRead;
socket.once('lookup', () => { dnsLookupAt = monotonicTime(); });
socket.once('connectionAttempt', () => { connectStart = monotonicTime(); });
socket.once('connect', () => { connectEnd = monotonicTime(); });
socket.once('secureConnect', () => { tlsHandshakeAt = monotonicTime(); });
});
proxyReq.end(proxyBody);
});
},
fulfill: async ({ status, headers, body, isBase64 }) => {
res.statusCode = status;
for (const { name, value } of headers)
res.appendHeader(name, value);
res.sendDate = false;
res.end(Buffer.from(body, isBase64 ? 'base64' : 'utf-8'));
},
});
if (!correlation)
return await route.continue({ isFallback: false });
if (this._matches?.(req.url!))
this.emit(MockingProxy.Events.Route, route);
else
await route.continue({ isFallback: false });
}
addRouteInFlight(route: Route): void {
// no-op, might be useful for warnings
}
removeRouteInFlight(route: Route): void {
// no-op, might be useful for warnings
}
}
function headersArray(req: Pick<http.IncomingMessage, 'headersDistinct'>): HeadersArray {
return Object.entries(req.headersDistinct).flatMap(([name, values = []]) => values.map(value => ({ name, value })));
}
function headersArrayToOutgoingHeaders(headers: HeadersArray) {
const result: http.OutgoingHttpHeaders = {};
for (const { name, value } of headers) {
if (result[name] === undefined)
result[name] = value;
else if (Array.isArray(result[name]))
result[name].push(value);
else
result[name] = [result[name] as string, value];
}
return result;
}
async function collectBody(req: http.IncomingMessage) {
return await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
export class WorkerHttpServer extends HttpServer {
override _handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean {
return false;
}
}

View file

@ -14,7 +14,6 @@
* limitations under the License.
*/
import type * as contexts from './browserContext';
import type * as pages from './page';
import type * as frames from './frames';
import type * as types from './types';
@ -88,6 +87,13 @@ export function stripFragmentFromUrl(url: string): string {
return url.substring(0, url.indexOf('#'));
}
export interface RequestContext extends SdkObject {
fetchRequest: APIRequestContext;
addRouteInFlight(route: Route): void;
removeRouteInFlight(route: Route): void;
}
export class Request extends SdkObject {
private _response: Response | null = null;
private _redirectedFrom: Request | null;
@ -103,14 +109,14 @@ export class Request extends SdkObject {
private _headersMap = new Map<string, string>();
readonly _frame: frames.Frame | null = null;
readonly _serviceWorker: pages.Worker | null = null;
readonly _context: contexts.BrowserContext;
readonly _context: RequestContext;
private _rawRequestHeadersPromise = new ManualPromise<HeadersArray>();
private _waitForResponsePromise = new ManualPromise<Response | null>();
_responseEndTiming = -1;
private _overrides: NormalizedContinueOverrides | undefined;
private _bodySize: number | undefined;
constructor(context: contexts.BrowserContext, frame: frames.Frame | null, serviceWorker: pages.Worker | null, redirectedFrom: Request | null, documentId: string | undefined,
constructor(context: RequestContext, frame: frames.Frame | null, serviceWorker: pages.Worker | null, redirectedFrom: Request | null, documentId: string | undefined,
url: string, resourceType: string, method: string, postData: Buffer | null, headers: HeadersArray) {
super(frame || context, 'request');
assert(!url.startsWith('data:'), 'Data urls should not fire requests');
@ -346,7 +352,7 @@ export class Route extends SdkObject {
export type RouteHandler = (route: Route, request: Request) => boolean;
type GetResponseBodyCallback = () => Promise<Buffer>;
export type GetResponseBodyCallback = () => Promise<Buffer>;
export type ResourceTiming = {
startTime: number;

View file

@ -213,13 +213,20 @@ export class HttpServer {
readable.pipe(response);
}
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
_handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean {
if (request.method === 'OPTIONS') {
response.writeHead(200);
response.end();
return;
return true;
}
return false;
}
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
if (this._handleCORS(request, response))
return;
request.on('error', () => response.end());
try {
if (!request.url) {

View file

@ -24,7 +24,10 @@ import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
import { rootTestType } from './common/testType';
import type { ContextReuseMode } from './common/config';
import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
import type { MockingProxy } from '../../playwright-core/src/client/mockingProxy';
import type { BrowserContext as BrowserContextImpl } from '../../playwright-core/src/client/browserContext';
import { currentTestInfo } from './common/globals';
import type { LocalUtils } from 'playwright-core/lib/client/localUtils';
export { expect } from './matchers/expect';
export const _baseTest: TestType<{}, {}> = rootTestType.test;
@ -54,6 +57,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
_optionContextReuseMode: ContextReuseMode,
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
_reuseContext: boolean,
_mockingProxy?: MockingProxy,
};
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
@ -71,6 +75,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
screenshot: ['off', { scope: 'worker', option: true }],
video: ['off', { scope: 'worker', option: true }],
trace: ['off', { scope: 'worker', option: true }],
mockingProxy: [undefined, { scope: 'worker', option: true }],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
const options: LaunchOptions = {
@ -119,6 +124,14 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
}, true);
}, { scope: 'worker', timeout: 0 }],
_mockingProxy: [async ({ mockingProxy: mockingProxyOption, playwright }, use) => {
if (!mockingProxyOption)
return await use(undefined);
const localUtils: LocalUtils = (playwright as any)._connection.localUtils();
const { mockingProxy } = await localUtils._channel.newMockingProxy({});
await use((mockingProxy as any)._object);
}, { scope: 'worker', box: true }],
acceptDownloads: [({ contextOptions }, use) => use(contextOptions.acceptDownloads ?? true), { option: true }],
bypassCSP: [({ contextOptions }, use) => use(contextOptions.bypassCSP ?? false), { option: true }],
colorScheme: [({ contextOptions }, use) => use(contextOptions.colorScheme === undefined ? 'light' : contextOptions.colorScheme), { option: true }],
@ -247,7 +260,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
}
}, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any],
_setupArtifacts: [async ({ playwright, screenshot }, use, testInfo) => {
_setupArtifacts: [async ({ playwright, screenshot, _mockingProxy }, use, testInfo) => {
// This fixture has a separate zero-timeout slot to ensure that artifact collection
// happens even after some fixtures or hooks time out.
// Now that default test timeout is known, we can replace zero with an actual value.
@ -300,7 +313,9 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
if (!keepTestTimeout)
currentTestInfo()?._setDebugMode();
},
runAfterCreateBrowserContext: async (context: BrowserContext) => {
runAfterCreateBrowserContext: async (context: BrowserContextImpl) => {
if (_mockingProxy)
await context._subscribeToMockingProxy(_mockingProxy);
await artifactsRecorder?.didCreateBrowserContext(context);
const testInfo = currentTestInfo();
if (testInfo)
@ -348,7 +363,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
size: typeof video === 'string' ? undefined : video.size,
}
} : {};
const context = await browser.newContext({ ...videoOptions, ...options });
const context = await browser.newContext({ ...videoOptions, ...options }) as BrowserContextImpl;
const contextData: { pagesWithVideo: Page[] } = { pagesWithVideo: [] };
contexts.set(context, contextData);
if (captureVideo)

View file

@ -6157,6 +6157,22 @@ export interface PlaywrightWorkerOptions {
* Learn more about [recording video](https://playwright.dev/docs/test-use-options#recording-options).
*/
video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize };
/**
* **Usage**
*
* ```js
* // playwright.config.ts
* import { defineConfig } from '@playwright/test';
*
* export default defineConfig({
* use: {
* mockingProxy: true
* },
* });
* ```
*
*/
mockingProxy: boolean | undefined;
}
export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';

View file

@ -49,7 +49,6 @@ export type InitializerTraits<T> =
T extends FrameChannel ? FrameInitializer :
T extends PageChannel ? PageInitializer :
T extends BrowserContextChannel ? BrowserContextInitializer :
T extends EventTargetChannel ? EventTargetInitializer :
T extends BrowserChannel ? BrowserInitializer :
T extends BrowserTypeChannel ? BrowserTypeInitializer :
T extends SelectorsChannel ? SelectorsInitializer :
@ -57,7 +56,9 @@ export type InitializerTraits<T> =
T extends DebugControllerChannel ? DebugControllerInitializer :
T extends PlaywrightChannel ? PlaywrightInitializer :
T extends RootChannel ? RootInitializer :
T extends MockingProxyChannel ? MockingProxyInitializer :
T extends LocalUtilsChannel ? LocalUtilsInitializer :
T extends EventTargetChannel ? EventTargetInitializer :
T extends APIRequestContextChannel ? APIRequestContextInitializer :
object;
@ -87,7 +88,6 @@ export type EventsTraits<T> =
T extends FrameChannel ? FrameEvents :
T extends PageChannel ? PageEvents :
T extends BrowserContextChannel ? BrowserContextEvents :
T extends EventTargetChannel ? EventTargetEvents :
T extends BrowserChannel ? BrowserEvents :
T extends BrowserTypeChannel ? BrowserTypeEvents :
T extends SelectorsChannel ? SelectorsEvents :
@ -95,7 +95,9 @@ export type EventsTraits<T> =
T extends DebugControllerChannel ? DebugControllerEvents :
T extends PlaywrightChannel ? PlaywrightEvents :
T extends RootChannel ? RootEvents :
T extends MockingProxyChannel ? MockingProxyEvents :
T extends LocalUtilsChannel ? LocalUtilsEvents :
T extends EventTargetChannel ? EventTargetEvents :
T extends APIRequestContextChannel ? APIRequestContextEvents :
undefined;
@ -125,7 +127,6 @@ export type EventTargetTraits<T> =
T extends FrameChannel ? FrameEventTarget :
T extends PageChannel ? PageEventTarget :
T extends BrowserContextChannel ? BrowserContextEventTarget :
T extends EventTargetChannel ? EventTargetEventTarget :
T extends BrowserChannel ? BrowserEventTarget :
T extends BrowserTypeChannel ? BrowserTypeEventTarget :
T extends SelectorsChannel ? SelectorsEventTarget :
@ -133,7 +134,9 @@ export type EventTargetTraits<T> =
T extends DebugControllerChannel ? DebugControllerEventTarget :
T extends PlaywrightChannel ? PlaywrightEventTarget :
T extends RootChannel ? RootEventTarget :
T extends MockingProxyChannel ? MockingProxyEventTarget :
T extends LocalUtilsChannel ? LocalUtilsEventTarget :
T extends EventTargetChannel ? EventTargetEventTarget :
T extends APIRequestContextChannel ? APIRequestContextEventTarget :
undefined;
@ -404,6 +407,31 @@ export type APIResponse = {
};
export type LifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
// ----------- EventTarget -----------
export type EventTargetInitializer = {};
export interface EventTargetEventTarget {
}
export interface EventTargetChannel extends EventTargetEventTarget, Channel {
_type_EventTarget: boolean;
waitForEventInfo(params: EventTargetWaitForEventInfoParams, metadata?: CallMetadata): Promise<EventTargetWaitForEventInfoResult>;
}
export type EventTargetWaitForEventInfoParams = {
info: {
waitId: string,
phase: 'before' | 'after' | 'log',
event?: string,
message?: string,
error?: string,
},
};
export type EventTargetWaitForEventInfoOptions = {
};
export type EventTargetWaitForEventInfoResult = void;
export interface EventTargetEvents {
}
// ----------- LocalUtils -----------
export type LocalUtilsInitializer = {
deviceDescriptors: {
@ -438,6 +466,7 @@ export interface LocalUtilsChannel extends LocalUtilsEventTarget, Channel {
tracingStarted(params: LocalUtilsTracingStartedParams, metadata?: CallMetadata): Promise<LocalUtilsTracingStartedResult>;
addStackToTracingNoReply(params: LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata): Promise<LocalUtilsAddStackToTracingNoReplyResult>;
traceDiscarded(params: LocalUtilsTraceDiscardedParams, metadata?: CallMetadata): Promise<LocalUtilsTraceDiscardedResult>;
newMockingProxy(params?: LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise<LocalUtilsNewMockingProxyResult>;
}
export type LocalUtilsZipParams = {
zipFile: string,
@ -537,10 +566,72 @@ export type LocalUtilsTraceDiscardedOptions = {
};
export type LocalUtilsTraceDiscardedResult = void;
export type LocalUtilsNewMockingProxyParams = {};
export type LocalUtilsNewMockingProxyOptions = {};
export type LocalUtilsNewMockingProxyResult = {
mockingProxy: MockingProxyChannel,
};
export interface LocalUtilsEvents {
}
// ----------- MockingProxy -----------
export type MockingProxyInitializer = {
port: number,
requestContext: APIRequestContextChannel,
};
export interface MockingProxyEventTarget {
on(event: 'route', callback: (params: MockingProxyRouteEvent) => void): this;
on(event: 'request', callback: (params: MockingProxyRequestEvent) => void): this;
on(event: 'requestFailed', callback: (params: MockingProxyRequestFailedEvent) => void): this;
on(event: 'requestFinished', callback: (params: MockingProxyRequestFinishedEvent) => void): this;
on(event: 'response', callback: (params: MockingProxyResponseEvent) => void): this;
}
export interface MockingProxyChannel extends MockingProxyEventTarget, EventTargetChannel {
_type_MockingProxy: boolean;
setInterceptionPatterns(params: MockingProxySetInterceptionPatternsParams, metadata?: CallMetadata): Promise<MockingProxySetInterceptionPatternsResult>;
}
export type MockingProxyRouteEvent = {
route: RouteChannel,
};
export type MockingProxyRequestEvent = {
request: RequestChannel,
correlation: string,
};
export type MockingProxyRequestFailedEvent = {
request: RequestChannel,
failureText?: string,
responseEndTiming: number,
};
export type MockingProxyRequestFinishedEvent = {
request: RequestChannel,
response?: ResponseChannel,
responseEndTiming: number,
};
export type MockingProxyResponseEvent = {
response: ResponseChannel,
page?: PageChannel,
};
export type MockingProxySetInterceptionPatternsParams = {
patterns: {
glob?: string,
regexSource?: string,
regexFlags?: string,
}[],
};
export type MockingProxySetInterceptionPatternsOptions = {
};
export type MockingProxySetInterceptionPatternsResult = void;
export interface MockingProxyEvents {
'route': MockingProxyRouteEvent;
'request': MockingProxyRequestEvent;
'requestFailed': MockingProxyRequestFailedEvent;
'requestFinished': MockingProxyRequestFinishedEvent;
'response': MockingProxyResponseEvent;
}
// ----------- Root -----------
export type RootInitializer = {};
export interface RootEventTarget {
@ -1460,31 +1551,6 @@ export interface BrowserEvents {
'close': BrowserCloseEvent;
}
// ----------- EventTarget -----------
export type EventTargetInitializer = {};
export interface EventTargetEventTarget {
}
export interface EventTargetChannel extends EventTargetEventTarget, Channel {
_type_EventTarget: boolean;
waitForEventInfo(params: EventTargetWaitForEventInfoParams, metadata?: CallMetadata): Promise<EventTargetWaitForEventInfoResult>;
}
export type EventTargetWaitForEventInfoParams = {
info: {
waitId: string,
phase: 'before' | 'after' | 'log',
event?: string,
message?: string,
error?: string,
},
};
export type EventTargetWaitForEventInfoOptions = {
};
export type EventTargetWaitForEventInfoResult = void;
export interface EventTargetEvents {
}
// ----------- BrowserContext -----------
export type BrowserContextInitializer = {
isChromium: boolean,

View file

@ -526,6 +526,28 @@ ContextOptions:
- allow
- block
EventTarget:
type: interface
commands:
waitForEventInfo:
parameters:
info:
type: object
properties:
waitId: string
phase:
type: enum
literals:
- before
- after
- log
event: string?
message: string?
error: string?
flags:
snapshot: true
LocalUtils:
type: interface
@ -647,6 +669,58 @@ LocalUtils:
parameters:
stacksId: string
newMockingProxy:
returns:
mockingProxy: MockingProxy
MockingProxy:
type: interface
extends: EventTarget
initializer:
port: number
requestContext: APIRequestContext
commands:
setInterceptionPatterns:
parameters:
patterns:
type: array
items:
type: object
properties:
glob: string?
regexSource: string?
regexFlags: string?
events:
route:
parameters:
route: Route
request:
parameters:
request: Request
correlation: string
requestFailed:
parameters:
request: Request
failureText: string?
responseEndTiming: number
requestFinished:
parameters:
request: Request
response: Response?
responseEndTiming: number
response:
parameters:
response: Response
page: Page?
Root:
type: interface
@ -1030,29 +1104,6 @@ ConsoleMessage:
lineNumber: number
columnNumber: number
EventTarget:
type: interface
commands:
waitForEventInfo:
parameters:
info:
type: object
properties:
waitId: string
phase:
type: enum
literals:
- before
- after
- log
event: string?
message: string?
error: string?
flags:
snapshot: true
BrowserContext:
type: interface

View file

@ -0,0 +1,298 @@
/**
* 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 { test, expect } from './playwright-test-fixtures';
import http from 'http';
const config = {
'playwright.config.ts': `
module.exports = {
use: {
mockingProxy: true,
ignoreHTTPSErrors: true,
}
};
`,
};
test('inject mode', async ({ runInlineTest, server }) => {
server.setRoute('/page', (req, res) => {
res.end(req.headers['x-playwright-proxy'] ? 'proxy url injected' : 'proxy url missing');
});
const result = await runInlineTest({
...config,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('foo', async ({ page }) => {
await page.goto('${server.PREFIX}/page');
expect(await page.textContent('body')).toEqual('proxy url injected');
});
`
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('routes are reset between tests', async ({ runInlineTest, server, request }) => {
server.setRoute('/fallback', async (req, res) => {
res.end('fallback');
});
server.setRoute('/page', async (req, res) => {
const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? '');
const response = await request.get(proxyURL + server.PREFIX + '/fallback');
res.end(await response.body());
});
const result = await runInlineTest({
...config,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('first', async ({ page, request, context }) => {
await context.route('${server.PREFIX}/fallback', route => route.fulfill({ body: 'first' }));
await page.goto('${server.PREFIX}/page');
expect(await page.textContent('body')).toEqual('first');
});
test('second', async ({ page, request, context }) => {
await context.route('${server.PREFIX}/fallback', route => route.fallback());
await page.goto('${server.PREFIX}/page');
expect(await page.textContent('body')).toEqual('fallback');
});
`
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(2);
});
test('all properties are populated', async ({ runInlineTest, server, request }) => {
server.setRoute('/fallback', async (req, res) => {
res.statusCode = 201;
res.setHeader('foo', 'bar');
res.end('fallback');
});
server.setRoute('/page', async (req, res) => {
const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? '');
const response = await request.get(proxyURL + server.PREFIX + '/fallback');
res.end(await response.body());
});
const result = await runInlineTest({
...config,
'a.test.js': `
import { test, expect } from '@playwright/test';
test('test', async ({ page, context }) => {
let request;
await context.route('${server.PREFIX}/fallback', route => {
request = route.request();
route.continue();
});
await page.goto('${server.PREFIX}/page');
expect(await page.textContent('body')).toEqual('fallback');
const response = await request.response();
expect(request.url()).toBe('${server.PREFIX}/fallback');
expect(response.url()).toBe('${server.PREFIX}/fallback');
expect(response.status()).toBe(201);
expect(await response.headersArray()).toContainEqual({ name: 'foo', value: 'bar' });
expect(await response.body()).toEqual(Buffer.from('fallback'));
expect(await response.finished()).toBe(null);
expect(request.serviceWorker()).toBe(null);
expect(request.frame()).not.toBe(null);
expect(request.failure()).toBe(null);
expect(request.isNavigationRequest()).toBe(false);
expect(request.redirectedFrom()).toBe(null);
expect(request.redirectedTo()).toBe(null);
expect(request.resourceType()).toBe(''); // TODO: should this be different?
expect(request.method()).toBe('GET');
expect(await request.sizes()).toEqual({
requestBodySize: 0,
requestHeadersSize: 176,
responseBodySize: 8,
responseHeadersSize: 137,
});
expect(request.timing()).toEqual({
'connectEnd': expect.any(Number),
'connectStart': expect.any(Number),
'domainLookupEnd': expect.any(Number),
'domainLookupStart': -1,
'requestStart': expect.any(Number),
'responseEnd': expect.any(Number),
'responseStart': expect.any(Number),
'secureConnectionStart': -1,
'startTime': expect.any(Number),
});
expect(await response.securityDetails()).toBe(null);
expect(await response.serverAddr()).toEqual({
ipAddress: expect.any(String),
port: expect.any(Number),
});
});
`
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('securityDetails', async ({ httpsServer, request, runInlineTest }) => {
httpsServer.setRoute('/fallback', async (req, res) => {
res.statusCode = 201;
res.setHeader('foo', 'bar');
res.end('fallback');
});
httpsServer.setRoute('/page', async (req, res) => {
const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? '');
const response = await request.get(proxyURL + httpsServer.PREFIX + '/fallback', { ignoreHTTPSErrors: true });
res.end(await response.body());
});
const result = await runInlineTest({
...config,
'a.test.js': `
import { test, expect } from '@playwright/test';
test('test', async ({ page, context }) => {
let request;
await context.route('${httpsServer.PREFIX}/fallback', route => {
request = route.request();
route.continue();
});
await page.goto('${httpsServer.PREFIX}/page');
expect(await page.textContent('body')).toEqual('fallback');
const response = await request.response();
expect(await response.securityDetails()).toEqual({
"issuer": "playwright-test",
"protocol": expect.any(String),
"subjectName": "playwright-test",
"validFrom": expect.any(Number),
"validTo": expect.any(Number)
});
});
`
}, { workers: 1 }, { NODE_TLS_REJECT_UNAUTHORIZED: '0' });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('aborting', async ({ runInlineTest, server }) => {
server.setRoute('/page', async (req, res) => {
const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? '');
const request = http.get(proxyURL + server.PREFIX + '/fallback');
request.on('error', () => res.end('aborted'));
request.pipe(res);
});
const result = await runInlineTest({
...config,
'a.test.js': `
import { test, expect } from '@playwright/test';
test('test', async ({ page, context, request }) => {
await context.route('${server.PREFIX}/fallback', route => route.abort());
const response = await request.get('${server.PREFIX}/page')
expect(await response.text()).toEqual('aborted');
});
`
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('fetch', async ({ runInlineTest, server, request }) => {
server.setRoute('/fallback', async (req, res) => {
res.statusCode = 201;
res.setHeader('foo', 'bar');
res.end('fallback');
});
server.setRoute('/page', async (req, res) => {
const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? '');
const response = await request.get(proxyURL + server.PREFIX + '/fallback');
res.end(await response.body());
});
const result = await runInlineTest({
...config,
'a.test.js': `
import { test, expect } from '@playwright/test';
test('test', async ({ page, context }) => {
let request;
await context.route('${server.PREFIX}/fallback', async route => {
route.fulfill({ response: await route.fetch() });
});
await page.goto('${server.PREFIX}/page');
expect(await page.textContent('body')).toEqual('fallback');
});
`
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('inject mode knows originating page', async ({ runInlineTest, server, request }) => {
server.setRoute('/fallback', async (req, res) => {
res.end('fallback');
});
server.setRoute('/page', async (req, res) => {
const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? '');
const response = await request.get(proxyURL + server.PREFIX + '/fallback');
res.end(await response.body());
});
const result = await runInlineTest({
...config,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('first', async ({ page, context }) => {
await page.route('${server.PREFIX}/fallback', route => route.fulfill({ body: 'first' }));
await page.goto('${server.PREFIX}/page');
expect(await page.textContent('body')).toEqual('first');
});
`
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('failure', async ({ runInlineTest, server, request }) => {
server.setRoute('/fallback', async (req, res) => {
res.socket.destroy();
});
server.setRoute('/page', async (req, res) => {
const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? '');
const response = await request.get(proxyURL + server.PREFIX + '/fallback');
res.end(await response.body());
});
const result = await runInlineTest({
...config,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('first', async ({ page, context }) => {
let request;
await page.route('${server.PREFIX}/fallback', route => {
request = route.request();
route.continue();
});
await page.goto('${server.PREFIX}/page');
expect(request.failure()).toEqual({ errorText: expect.any(String) });
});
`
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});

View file

@ -236,6 +236,7 @@ export interface PlaywrightWorkerOptions {
screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick<PageScreenshotOptions, 'fullPage' | 'omitBackground'>;
trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean, screenshots?: boolean, sources?: boolean, attachments?: boolean };
video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize };
mockingProxy: boolean | undefined;
}
export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';