This commit is contained in:
Simon Knott 2025-02-07 22:06:52 +02:00 committed by GitHub
commit ea480a0d08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1481 additions and 167 deletions

View file

@ -554,3 +554,264 @@ 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:
```js
export default defineConfig({
use: { mockingProxy: 'inject-via-header' }
});
```
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 [`method: BrowserContext.route`] and [`method: Page.route`], just like browser requests:
```js
// 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 setting up a global interceptor:
```js
import { axios } from 'axios';
axios.interceptors.request.use(async config => {
const headers = getCurrentRequestHeaders(); // this line looks different for each application
const proxy = decodeURIComponent(headers.get('x-playwright-proxy') ?? '');
config.url = new URL(proxy + config.url, config.baseURL).toString();
return config;
});
```
```js
import { setGlobalDispatcher, getGlobalDispatcher } from 'undici';
const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
const headers = getCurrentRequestHeaders(); // this line looks different for each application
const proxy = decodeURIComponent(headers.get('x-playwright-proxy') ?? '');
const newURL = new URL(proxy + opts.origin + opts.path);
opts.origin = newURL.origin;
opts.path = newURL.pathname;
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 prepended 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!
:::
:::note
Known Limitations:
1. The mocking proxy is experimental and subject to change.
2. The injected `x-playwright-proxy` header affects CORS and might turn [simple requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests) into requests that require a preflight.
3. Requests on the server that were not made in response to a browser request, like those triggered by CRON job, won't be routed because they don't have access to the `x-playwright-proxy` header.
4. On Firefox, the first requests after page open might not be intercepted by the mocking proxy.
5. `defaultContextOptions` aren't applied when using `route.fetch`.
:::
### Recipes
* langs: js
#### Next.js
* langs: js
Monkey-patch `globalThis.fetch` in your `instrumentation.ts` file:
```js
// 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:
```js
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):
```js
// 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);
},
]),
)
]
};
/* ... */
```
#### Astro
* langs: js
Set up a server-side fetch override in an Astro integration:
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import type { AstroIntegration } from 'astro';
import { AsyncLocalStorage } from 'async_hooks';
const playwrightMockingProxy: AstroIntegration = {
name: 'playwrightMockingProxy',
hooks: {
'astro:server:setup': async astro => {
if (process.env.NODE_ENV !== 'test')
return;
const proxyStorage = new AsyncLocalStorage<string>();
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const proxy = proxyStorage.getStore();
if (!proxy)
return originalFetch(input, init);
const request = new Request(input, init);
return originalFetch(proxy + request.url, request);
};
astro.server.middlewares.use((req, res, next) => {
const header = req.headers['x-playwright-proxy'] as string;
if (typeof header !== 'string')
return next();
proxyStorage.run(decodeURIComponent(header), next);
});
},
}
};
export default defineConfig({
integrations: [
playwrightMockingProxy
]
});
```
#### Nuxt
```js
// server/plugins/playwright-mocking-proxy.ts
import { getGlobalDispatcher, setGlobalDispatcher } from 'undici';
import { useEvent, getRequestHeader } from '#imports';
export default defineNitroPlugin(() => {
if (process.env.NODE_ENV !== 'test')
return;
const proxiedDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
const isInternal = opts.path.startsWith('/__nuxt');
const proxy = getRequestHeader(useEvent(), 'x-playwright-proxy');
if (proxy && !isInternal) {
const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path);
opts.origin = newURL.origin;
opts.path = newURL.pathname;
}
return dispatch(opts, handler);
});
setGlobalDispatcher(proxiedDispatcher);
});
```
```js
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: {
asyncContext: true,
}
}
});
```

View file

@ -676,3 +676,19 @@ export default defineConfig({
},
});
```
## property: TestOptions.mockingProxy
* since: v1.51
- type: <[MockingProxyMode]<"off"|"inject-via-header">> 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: 'inject-via-header'
},
});
```

View file

@ -81,7 +81,7 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> {
options = { ...this._browserType._playwright._defaultContextOptions, ...options };
const contextOptions = await prepareBrowserContextParams(options);
const contextOptions = await prepareBrowserContextParams(options, this._browserType);
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
const context = BrowserContext.from(response.context);
await this._browserType._didCreateContext(context, contextOptions, this._options, options.logger || this._logger);

View file

@ -90,7 +90,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._apiRequestContext = 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);
@ -136,7 +140,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
});
this._channel.on('request', ({ request, page }) => this._onRequest(network.Request.from(request), Page.fromNullable(page)));
this._channel.on('requestFailed', ({ request, failureText, responseEndTiming, page }) => this._onRequestFailed(network.Request.from(request), responseEndTiming, failureText, Page.fromNullable(page)));
this._channel.on('requestFinished', params => this._onRequestFinished(params));
this._channel.on('requestFinished', ({ request, response, page, responseEndTiming }) => this._onRequestFinished(network.Request.from(request), network.Response.fromNullable(response), Page.fromNullable(page), responseEndTiming));
this._channel.on('response', ({ response, page }) => this._onResponse(network.Response.from(response), Page.fromNullable(page)));
this._closedPromise = new Promise(f => this.once(Events.BrowserContext.Close, f));
@ -164,19 +168,19 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
page._opener.emit(Events.Page.Popup, page);
}
private _onRequest(request: network.Request, page: Page | null) {
_onRequest(request: network.Request, page: Page | null) {
this.emit(Events.BrowserContext.Request, request);
if (page)
page.emit(Events.Page.Request, request);
}
private _onResponse(response: network.Response, page: Page | null) {
_onResponse(response: network.Response, page: Page | null) {
this.emit(Events.BrowserContext.Response, response);
if (page)
page.emit(Events.Page.Response, response);
}
private _onRequestFailed(request: network.Request, responseEndTiming: number, failureText: string | undefined, page: Page | null) {
_onRequestFailed(request: network.Request, responseEndTiming: number, failureText: string | undefined, page: Page | null) {
request._failureText = failureText || null;
request._setResponseEndTiming(responseEndTiming);
this.emit(Events.BrowserContext.RequestFailed, request);
@ -184,11 +188,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
page.emit(Events.Page.RequestFailed, request);
}
private _onRequestFinished(params: channels.BrowserContextRequestFinishedEvent) {
const { responseEndTiming } = params;
const request = network.Request.from(params.request);
const response = network.Response.fromNullable(params.response);
const page = Page.fromNullable(params.page);
_onRequestFinished(request: network.Request, response: network.Response | null, page: Page | null, responseEndTiming: number) {
request._setResponseEndTiming(responseEndTiming);
this.emit(Events.BrowserContext.RequestFinished, request);
if (page)
@ -198,7 +198,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) {
@ -521,7 +520,7 @@ function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): c
};
}
export async function prepareBrowserContextParams(options: BrowserContextOptions): Promise<channels.BrowserNewContextParams> {
export async function prepareBrowserContextParams(options: BrowserContextOptions, type?: BrowserType): Promise<channels.BrowserNewContextParams> {
if (options.videoSize && !options.videosPath)
throw new Error(`"videoSize" option requires "videosPath" to be specified`);
if (options.extraHTTPHeaders)
@ -540,6 +539,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions
contrast: options.contrast === null ? 'no-override' : options.contrast,
acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads),
clientCertificates: await toClientCertificatesProtocol(options.clientCertificates),
mockingProxyBaseURL: type?._playwright._mockingProxy?.baseURL(),
};
if (!contextParams.recordVideo && options.videosPath) {
contextParams.recordVideo = {

View file

@ -89,7 +89,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
options = { ...this._playwright._defaultLaunchOptions, ...this._playwright._defaultContextOptions, ...options };
const contextParams = await prepareBrowserContextParams(options);
const contextParams = await prepareBrowserContextParams(options, this);
const persistentParams: channels.BrowserTypeLaunchPersistentContextParams = {
...contextParams,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,

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

@ -45,7 +45,7 @@ export type FetchOptions = {
maxRetries?: number,
};
type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'tracesDir'> & {
export type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'tracesDir'> & {
extraHTTPHeaders?: Headers,
storageState?: string | SetStorageState,
clientCertificates?: ClientCertificate[];
@ -62,6 +62,10 @@ export class APIRequest implements api.APIRequest {
}
async newContext(options: NewContextOptions = {}): Promise<APIRequestContext> {
return await this._newContext(options, this._playwright._channel);
}
async _newContext(options: NewContextOptions = {}, channel: channels.PlaywrightChannel | channels.LocalUtilsChannel): Promise<APIRequestContext> {
options = {
...this._playwright._defaultContextOptions,
timeout: this._playwright._defaultContextTimeout,
@ -70,7 +74,7 @@ export class APIRequest implements api.APIRequest {
const storageState = typeof options.storageState === 'string' ?
JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) :
options.storageState;
const context = APIRequestContext.from((await this._playwright._channel.newRequest({
const context = APIRequestContext.from((await channel.newRequest({
...options,
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
storageState,

View file

@ -0,0 +1,79 @@
/**
* 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 type { APIRequestContext } from './fetch';
import { assert } from '../utils';
import type { Page } from './page';
import type { Playwright } from './playwright';
export class MockingProxy extends ChannelOwner<channels.MockingProxyChannel> {
_requestContext!: APIRequestContext;
_playwright!: Playwright;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.MockingProxyInitializer) {
super(parent, type, guid, initializer);
this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => {
const route = network.Route.from(params.route);
route._apiRequestContext = this._requestContext;
const page = route.request()._pageForMockingProxy!;
await page._onRoute(route);
});
this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => {
const page = this.findPage(params.correlation);
assert(page);
const request = network.Request.from(params.request);
request._pageForMockingProxy = page;
page.context()._onRequest(request, page);
});
this._channel.on('requestFailed', async (params: channels.MockingProxyRequestFailedEvent) => {
const request = network.Request.from(params.request);
const page = request._pageForMockingProxy!;
page.context()._onRequestFailed(request, params.responseEndTiming, params.failureText, page);
});
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);
const page = request._pageForMockingProxy!;
page.context()._onRequestFinished(request, response, page, responseEndTiming);
});
this._channel.on('response', async (params: channels.MockingProxyResponseEvent) => {
const response = network.Response.from(params.response);
const page = response.request()._pageForMockingProxy!;
page.context()._onResponse(response, page);
});
}
static from(channel: channels.MockingProxyChannel): MockingProxy {
return (channel as any)._object;
}
findPage(correlation: string): Page | undefined {
const guid = `page@${correlation}`;
return this._playwright._allPages().find(page => page._guid === guid);
}
baseURL() {
return this._initializer.baseURL;
}
}

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 = {};
_pageForMockingProxy: Page | null = null;
static from(request: channels.RequestChannel): Request {
return (request as any)._object;
@ -200,6 +201,8 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
}
frame(): Frame {
if (this._pageForMockingProxy)
throw new Error('Frame for this request is not available, because the request was issued on the server.');
if (!this._initializer.frame) {
assert(this.serviceWorker());
throw new Error('Service Worker requests do not have an associated frame.');
@ -216,7 +219,7 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
}
_safePage(): Page | null {
return Frame.fromNullable(this._initializer.frame)?._page || null;
return Frame.fromNullable(this._initializer.frame)?._page ?? null;
}
serviceWorker(): Worker | null {
@ -291,7 +294,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;
_apiRequestContext!: APIRequestContext;
_didThrow: boolean = false;
static from(route: channels.RouteChannel): Route {
@ -339,7 +342,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._apiRequestContext._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._apiRequestContext = 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

@ -22,6 +22,7 @@ import { ChannelOwner } from './channelOwner';
import { Electron } from './electron';
import { APIRequest } from './fetch';
import { Selectors, SelectorsOwner } from './selectors';
import { MockingProxy } from './mockingProxy';
import type { BrowserContextOptions, LaunchOptions } from 'playwright-core';
export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
@ -36,6 +37,7 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
selectors: Selectors;
readonly request: APIRequest;
readonly errors: { TimeoutError: typeof TimeoutError };
_mockingProxy?: MockingProxy;
// Instrumentation.
_defaultLaunchOptions?: LaunchOptions;
@ -92,4 +94,14 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
_allPages() {
return this._allContexts().flatMap(context => context.pages());
}
async _startMockingProxy() {
const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel);
const result = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel });
this._mockingProxy = MockingProxy.from(result.mockingProxy);
this._mockingProxy._requestContext = requestContext;
this._mockingProxy._playwright = this;
return this._mockingProxy;
}
}

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

@ -257,6 +257,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,
@ -344,6 +367,69 @@ scheme.LocalUtilsTraceDiscardedParams = tObject({
stacksId: tString,
});
scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({}));
scheme.LocalUtilsNewRequestParams = tObject({
baseURL: tOptional(tString),
userAgent: tOptional(tString),
ignoreHTTPSErrors: tOptional(tBoolean),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
clientCertificates: tOptional(tArray(tObject({
origin: tString,
cert: tOptional(tBinary),
key: tOptional(tBinary),
passphrase: tOptional(tString),
pfx: tOptional(tBinary),
}))),
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
origin: tOptional(tString),
send: tOptional(tEnum(['always', 'unauthorized'])),
})),
proxy: tOptional(tObject({
server: tString,
bypass: tOptional(tString),
username: tOptional(tString),
password: tOptional(tString),
})),
timeout: tOptional(tNumber),
storageState: tOptional(tObject({
cookies: tOptional(tArray(tType('NetworkCookie'))),
origins: tOptional(tArray(tType('SetOriginStorage'))),
})),
tracesDir: tOptional(tString),
});
scheme.LocalUtilsNewRequestResult = tObject({
request: tChannel(['APIRequestContext']),
});
scheme.LocalUtilsNewMockingProxyParams = tObject({
requestContext: tChannel(['APIRequestContext']),
});
scheme.LocalUtilsNewMockingProxyResult = tObject({
mockingProxy: tChannel(['MockingProxy']),
});
scheme.MockingProxyInitializer = tObject({
baseURL: tString,
});
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']),
});
scheme.RootInitializer = tOptional(tObject({}));
scheme.RootInitializeParams = tObject({
sdkLanguage: tEnum(['javascript', 'python', 'java', 'csharp']),
@ -722,6 +808,7 @@ scheme.BrowserNewContextParams = tObject({
cookies: tOptional(tArray(tType('SetNetworkCookie'))),
origins: tOptional(tArray(tType('SetOriginStorage'))),
})),
mockingProxyBaseURL: tOptional(tString),
});
scheme.BrowserNewContextResult = tObject({
context: tChannel(['BrowserContext']),
@ -814,27 +901,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

@ -46,7 +46,7 @@ import { RecorderApp } from './recorder/recorderApp';
import * as storageScript from './storageScript';
import * as utilityScriptSerializers from './isomorphic/utilityScriptSerializers';
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 { MockingProxy } from '../mockingProxy';
import { MockingProxyDispatcher } from './mockingProxyDispatcher';
import { APIRequestContextDispatcher } from './networkDispatchers';
import { GlobalAPIRequestContext } from '../fetch';
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,6 +55,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
tmpDir: string | undefined,
callStacks: channels.ClientSideCallMetadata[]
}>();
private _playwright: Playwright;
constructor(scope: RootDispatcher, playwright: Playwright) {
const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils');
@ -59,6 +64,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
super(scope, localUtils, 'LocalUtils', {
deviceDescriptors,
});
this._playwright = playwright;
this._type_LocalUtils = true;
}
@ -273,6 +279,18 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
await removeFolders([session.tmpDir]);
this._stackSessions.delete(stacksId!);
}
async newRequest(params: channels.LocalUtilsNewRequestParams, metadata?: CallMetadata): Promise<channels.LocalUtilsNewRequestResult> {
const requestContext = new GlobalAPIRequestContext(this._playwright, params);
return { request: APIRequestContextDispatcher.from(this.parentScope(), requestContext) };
}
async newMockingProxy(params: channels.LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise<channels.LocalUtilsNewMockingProxyResult> {
const requestContext = (params.requestContext as APIRequestContextDispatcher)._object;
const mockingProxy = new MockingProxy(this._object, requestContext);
await mockingProxy.start();
return { mockingProxy: new MockingProxyDispatcher(this, mockingProxy) };
}
}
const redirectStatus = [301, 302, 303, 307, 308];

View file

@ -0,0 +1,58 @@
/**
* 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 { MockingProxy } from '../mockingProxy';
import { Dispatcher } from './dispatcher';
import type * as channels from '@protocol/channels';
import { RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
import type { Request } from '../network';
import type { LocalUtilsDispatcher } from './localUtilsDispatcher';
export class MockingProxyDispatcher extends Dispatcher<MockingProxy, channels.MockingProxyChannel, LocalUtilsDispatcher> implements channels.MockingProxyChannel {
_type_MockingProxy = true;
_type_EventTarget = true;
constructor(scope: LocalUtilsDispatcher, mockingProxy: MockingProxy) {
super(scope, mockingProxy, 'MockingProxy', {
baseURL: mockingProxy.baseURL(),
});
mockingProxy.onRoute = async route => {
const requestDispatcher = RequestDispatcher.from(this, 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, request), correlation });
});
this.addObjectListener(MockingProxy.Events.RequestFailed, (request: Request) => {
this._dispatchEvent('requestFailed', {
request: RequestDispatcher.from(this, request),
failureText: request._failureText ?? undefined,
responseEndTiming: request._responseEndTiming,
});
});
this.addObjectListener(MockingProxy.Events.RequestFinished, (request: Request) => {
this._dispatchEvent('requestFinished', {
request: RequestDispatcher.from(this, request),
response: ResponseDispatcher.fromNullable(this, request._existingResponse()),
responseEndTiming: request._responseEndTiming,
});
});
}
override _onDispose(): void {
this._object.stop();
}
}

View file

@ -26,30 +26,33 @@ import type { BrowserContextDispatcher } from './browserContextDispatcher';
import type { PageDispatcher } from './pageDispatcher';
import { FrameDispatcher } from './frameDispatcher';
import { WorkerDispatcher } from './pageDispatcher';
import type { MockingProxyDispatcher } from './mockingProxyDispatcher';
export class RequestDispatcher extends Dispatcher<Request, channels.RequestChannel, BrowserContextDispatcher | PageDispatcher | FrameDispatcher> implements channels.RequestChannel {
type NetworkScope = BrowserContextDispatcher | MockingProxyDispatcher;
export class RequestDispatcher extends Dispatcher<Request, channels.RequestChannel, NetworkScope | PageDispatcher | FrameDispatcher> implements channels.RequestChannel {
_type_Request: boolean;
private _browserContextDispatcher: BrowserContextDispatcher;
private _networkScope: NetworkScope;
static from(scope: BrowserContextDispatcher, request: Request): RequestDispatcher {
static from(scope: NetworkScope, request: Request): RequestDispatcher {
const result = existingDispatcher<RequestDispatcher>(request);
return result || new RequestDispatcher(scope, request);
}
static fromNullable(scope: BrowserContextDispatcher, request: Request | null): RequestDispatcher | undefined {
static fromNullable(scope: NetworkScope, request: Request | null): RequestDispatcher | undefined {
return request ? RequestDispatcher.from(scope, request) : undefined;
}
private constructor(scope: BrowserContextDispatcher, request: Request) {
private constructor(scope: NetworkScope, request: Request) {
const postData = request.postDataBuffer();
// Always try to attach request to the page, if not, frame.
const frame = request.frame();
const page = request.frame()?._page;
const pageDispatcher = page ? existingDispatcher<PageDispatcher>(page) : null;
const frameDispatcher = frame ? FrameDispatcher.from(scope, frame) : null;
const frameDispatcher = frame ? FrameDispatcher.from(scope as BrowserContextDispatcher, frame) : null;
super(pageDispatcher || frameDispatcher || scope, request, 'Request', {
frame: FrameDispatcher.fromNullable(scope, request.frame()),
serviceWorker: WorkerDispatcher.fromNullable(scope, request.serviceWorker()),
frame: FrameDispatcher.fromNullable(scope as BrowserContextDispatcher, request.frame()),
serviceWorker: WorkerDispatcher.fromNullable(scope as BrowserContextDispatcher, request.serviceWorker()),
url: request.url(),
resourceType: request.resourceType(),
method: request.method(),
@ -59,7 +62,7 @@ export class RequestDispatcher extends Dispatcher<Request, channels.RequestChann
redirectedFrom: RequestDispatcher.fromNullable(scope, request.redirectedFrom()),
});
this._type_Request = true;
this._browserContextDispatcher = scope;
this._networkScope = scope;
}
async rawRequestHeaders(params?: channels.RequestRawRequestHeadersParams): Promise<channels.RequestRawRequestHeadersResult> {
@ -67,20 +70,20 @@ export class RequestDispatcher extends Dispatcher<Request, channels.RequestChann
}
async response(): Promise<channels.RequestResponseResult> {
return { response: ResponseDispatcher.fromNullable(this._browserContextDispatcher, await this._object.response()) };
return { response: ResponseDispatcher.fromNullable(this._networkScope, await this._object.response()) };
}
}
export class ResponseDispatcher extends Dispatcher<Response, channels.ResponseChannel, RequestDispatcher> implements channels.ResponseChannel {
_type_Response = true;
static from(scope: BrowserContextDispatcher, response: Response): ResponseDispatcher {
static from(scope: NetworkScope, response: Response): ResponseDispatcher {
const result = existingDispatcher<ResponseDispatcher>(response);
const requestDispatcher = RequestDispatcher.from(scope, response.request());
return result || new ResponseDispatcher(requestDispatcher, response);
}
static fromNullable(scope: BrowserContextDispatcher, response: Response | null): ResponseDispatcher | undefined {
static fromNullable(scope: NetworkScope, response: Response | null): ResponseDispatcher | undefined {
return response ? ResponseDispatcher.from(scope, response) : undefined;
}

View file

@ -330,7 +330,7 @@ export class FFPage implements PageDelegate {
}
async updateExtraHTTPHeaders(): Promise<void> {
await this._session.send('Network.setExtraHTTPHeaders', { headers: this._page.extraHTTPHeaders() || [] });
await this._session.send('Network.setExtraHTTPHeaders', { headers: this._page.extraHTTPHeaders() });
}
async updateEmulatedViewportSize(): Promise<void> {

View file

@ -654,7 +654,7 @@ export class Frame extends SdkObject {
private async _gotoAction(progress: Progress, url: string, options: types.GotoOptions): Promise<network.Response | null> {
const waitUntil = verifyLifecycle('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil);
progress.log(`navigating to "${url}", waiting until "${waitUntil}"`);
const headers = this._page.extraHTTPHeaders() || [];
const headers = this._page.extraHTTPHeaders();
const refererHeader = headers.find(h => h.name.toLowerCase() === 'referer');
let referer = refererHeader ? refererHeader.value : undefined;
if (options.referer !== undefined) {

View file

@ -0,0 +1,260 @@
/**
* 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',
RequestFailed: 'requestfailed',
RequestFinished: 'requestfinished',
};
fetchRequest: APIRequestContext;
private _httpServer = new WorkerHttpServer();
onRoute = (route: Route) => route.continue({ isFallback: true });
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();
}
async stop() {
await this._httpServer.stop();
}
port() {
return this._httpServer.port();
}
baseURL() {
return `http://localhost:${this.port()}/`;
}
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'));
},
});
await this.onRoute(route);
}
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);
});
}
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 {
readonly 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

@ -366,8 +366,20 @@ export class Page extends SdkObject {
return this._delegate.updateExtraHTTPHeaders();
}
extraHTTPHeaders(): types.HeadersArray | undefined {
return this._extraHTTPHeaders;
extraHTTPHeaders(): types.HeadersArray {
return this.instrumentationHeaders().concat(this._extraHTTPHeaders ?? []);
}
instrumentationHeaders() {
const headers: channels.NameValue[] = [];
const mockingProxyBaseURL = this.context()._options.mockingProxyBaseURL;
if (mockingProxyBaseURL) {
const correlation = this.guid.substring('Page@'.length);
headers.push({ name: 'x-playwright-proxy', value: encodeURIComponent(mockingProxyBaseURL + `pw_meta:${correlation}/`) });
}
return headers;
}
async _onBindingCalled(payload: string, context: dom.FrameExecutionContext) {

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,6 +24,7 @@ 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 { BrowserContext as BrowserContextImpl } from '../../playwright-core/src/client/browserContext';
import type { Playwright as PlaywrightImpl } from '../../playwright-core/src/client/playwright';
import { currentTestInfo } from './common/globals';
export { expect } from './matchers/expect';
@ -56,6 +57,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
_optionContextReuseMode: ContextReuseMode,
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
_reuseContext: boolean,
_mockingProxy?: void,
_pageSnapshot: PageSnapshotOption,
};
@ -74,6 +76,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 }],
_pageSnapshot: ['off', { scope: 'worker', option: true }],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
@ -121,6 +124,12 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
}, true);
}, { scope: 'worker', timeout: 0 }],
_mockingProxy: [async ({ mockingProxy, playwright }, use) => {
if (mockingProxy === 'inject-via-header')
await (playwright as PlaywrightImpl)._startMockingProxy();
await use();
}, { scope: 'worker', box: true, auto: 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 }],
@ -295,7 +304,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
if (!keepTestTimeout)
currentTestInfo()?._setDebugMode();
},
runAfterCreateBrowserContext: async (context: BrowserContext) => {
runAfterCreateBrowserContext: async (context: BrowserContextImpl) => {
await artifactsRecorder?.didCreateBrowserContext(context);
const testInfo = currentTestInfo();
if (testInfo)
@ -343,7 +352,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

@ -6243,11 +6243,28 @@ 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: 'inject-via-header'
* },
* });
* ```
*
*/
mockingProxy: MockingProxyMode | undefined;
}
export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';
export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure';
export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry';
export type MockingProxyMode = 'off' | 'inject-via-header';
/**
* Playwright Test provides many options to configure test environment,

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;
@ -439,6 +442,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: {
@ -473,6 +501,8 @@ 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>;
newRequest(params: LocalUtilsNewRequestParams, metadata?: CallMetadata): Promise<LocalUtilsNewRequestResult>;
newMockingProxy(params: LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise<LocalUtilsNewMockingProxyResult>;
}
export type LocalUtilsZipParams = {
zipFile: string,
@ -572,10 +602,127 @@ export type LocalUtilsTraceDiscardedOptions = {
};
export type LocalUtilsTraceDiscardedResult = void;
export type LocalUtilsNewRequestParams = {
baseURL?: string,
userAgent?: string,
ignoreHTTPSErrors?: boolean,
extraHTTPHeaders?: NameValue[],
clientCertificates?: {
origin: string,
cert?: Binary,
key?: Binary,
passphrase?: string,
pfx?: Binary,
}[],
httpCredentials?: {
username: string,
password: string,
origin?: string,
send?: 'always' | 'unauthorized',
},
proxy?: {
server: string,
bypass?: string,
username?: string,
password?: string,
},
timeout?: number,
storageState?: {
cookies?: NetworkCookie[],
origins?: SetOriginStorage[],
},
tracesDir?: string,
};
export type LocalUtilsNewRequestOptions = {
baseURL?: string,
userAgent?: string,
ignoreHTTPSErrors?: boolean,
extraHTTPHeaders?: NameValue[],
clientCertificates?: {
origin: string,
cert?: Binary,
key?: Binary,
passphrase?: string,
pfx?: Binary,
}[],
httpCredentials?: {
username: string,
password: string,
origin?: string,
send?: 'always' | 'unauthorized',
},
proxy?: {
server: string,
bypass?: string,
username?: string,
password?: string,
},
timeout?: number,
storageState?: {
cookies?: NetworkCookie[],
origins?: SetOriginStorage[],
},
tracesDir?: string,
};
export type LocalUtilsNewRequestResult = {
request: APIRequestContextChannel,
};
export type LocalUtilsNewMockingProxyParams = {
requestContext: APIRequestContextChannel,
};
export type LocalUtilsNewMockingProxyOptions = {
};
export type LocalUtilsNewMockingProxyResult = {
mockingProxy: MockingProxyChannel,
};
export interface LocalUtilsEvents {
}
// ----------- MockingProxy -----------
export type MockingProxyInitializer = {
baseURL: string,
};
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;
}
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,
};
export interface MockingProxyEvents {
'route': MockingProxyRouteEvent;
'request': MockingProxyRequestEvent;
'requestFailed': MockingProxyRequestFailedEvent;
'requestFinished': MockingProxyRequestFinishedEvent;
'response': MockingProxyResponseEvent;
}
// ----------- Root -----------
export type RootInitializer = {};
export interface RootEventTarget {
@ -1260,6 +1407,7 @@ export type BrowserNewContextParams = {
cookies?: SetNetworkCookie[],
origins?: SetOriginStorage[],
},
mockingProxyBaseURL?: string,
};
export type BrowserNewContextOptions = {
noDefaultViewport?: boolean,
@ -1327,6 +1475,7 @@ export type BrowserNewContextOptions = {
cookies?: SetNetworkCookie[],
origins?: SetOriginStorage[],
},
mockingProxyBaseURL?: string,
};
export type BrowserNewContextResult = {
context: BrowserContextChannel,
@ -1501,31 +1650,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

@ -584,6 +584,78 @@ ContextOptions:
- allow
- block
NewRequestParameters:
type: mixin
properties:
baseURL: string?
userAgent: string?
ignoreHTTPSErrors: boolean?
extraHTTPHeaders:
type: array?
items: NameValue
clientCertificates:
type: array?
items:
type: object
properties:
origin: string
cert: binary?
key: binary?
passphrase: string?
pfx: binary?
httpCredentials:
type: object?
properties:
username: string
password: string
origin: string?
send:
type: enum?
literals:
- always
- unauthorized
proxy:
type: object?
properties:
server: string
bypass: string?
username: string?
password: string?
timeout: number?
storageState:
type: object?
properties:
cookies:
type: array?
items: NetworkCookie
origins:
type: array?
items: SetOriginStorage
tracesDir: string?
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
@ -705,6 +777,52 @@ LocalUtils:
parameters:
stacksId: string
newRequest:
parameters:
$mixin: NewRequestParameters
returns:
request: APIRequestContext
newMockingProxy:
parameters:
requestContext: APIRequestContext
returns:
mockingProxy: MockingProxy
MockingProxy:
type: interface
extends: EventTarget
initializer:
baseURL: 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
Root:
type: interface
@ -745,52 +863,7 @@ Playwright:
commands:
newRequest:
parameters:
baseURL: string?
userAgent: string?
ignoreHTTPSErrors: boolean?
extraHTTPHeaders:
type: array?
items: NameValue
clientCertificates:
type: array?
items:
type: object
properties:
origin: string
cert: binary?
key: binary?
passphrase: string?
pfx: binary?
httpCredentials:
type: object?
properties:
username: string
password: string
origin: string?
send:
type: enum?
literals:
- always
- unauthorized
proxy:
type: object?
properties:
server: string
bypass: string?
username: string?
password: string?
timeout: number?
storageState:
type: object?
properties:
cookies:
type: array?
items: NetworkCookie
origins:
type: array?
items: SetOriginStorage
tracesDir: string?
$mixin: NewRequestParameters
returns:
request: APIRequestContext
@ -1023,6 +1096,7 @@ Browser:
origins:
type: array?
items: SetOriginStorage
mockingProxyBaseURL: string?
returns:
context: BrowserContext
@ -1088,29 +1162,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: "inject-via-header",
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()).toThrowError("Frame for this request is not available, because the request was issued on the server.");
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: expect.any(Number),
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,11 +236,13 @@ 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: MockingProxyMode | undefined;
}
export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';
export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure';
export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry';
export type MockingProxyMode = 'off' | 'inject-via-header';
export interface PlaywrightTestOptions {
acceptDownloads: boolean;