This commit is contained in:
Simon Knott 2025-02-04 12:23:54 +01:00
parent aa2aef146d
commit 7a08cd6fa7
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
11 changed files with 60 additions and 42 deletions

View file

@ -642,6 +642,8 @@ Known Limitations:
1. The mocking proxy is experimental and subject to change. 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. 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. 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`.
::: :::

View file

@ -27,6 +27,7 @@ import { assert, headersObjectToArray, monotonicTime } from '../utils';
import type * as api from '../../types/types'; import type * as api from '../../types/types';
import { raceAgainstDeadline } from '../utils/timeoutRunner'; import { raceAgainstDeadline } from '../utils/timeoutRunner';
import type { Playwright } from './playwright'; import type { Playwright } from './playwright';
import type { Page } from './page';
export interface BrowserServerLauncher { export interface BrowserServerLauncher {
launchServer(options?: LaunchServerOptions): Promise<api.BrowserServer>; launchServer(options?: LaunchServerOptions): Promise<api.BrowserServer>;
@ -241,6 +242,14 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
context.setDefaultTimeout(this._defaultContextTimeout); context.setDefaultTimeout(this._defaultContextTimeout);
if (this._defaultContextNavigationTimeout !== undefined) if (this._defaultContextNavigationTimeout !== undefined)
context.setDefaultNavigationTimeout(this._defaultContextNavigationTimeout); context.setDefaultNavigationTimeout(this._defaultContextNavigationTimeout);
if (this._playwright._mockingProxy) {
context.on(Events.BrowserContext.Page, (page: Page) => {
// TODO: funnel through protocol, so these headers are known to the server browsercontext and can be applied earlier
page.setExtraHTTPHeaders(this._playwright._mockingProxy!.instrumentationHeaders(page));
});
}
await this._instrumentation.runAfterCreateBrowserContext(context); await this._instrumentation.runAfterCreateBrowserContext(context);
} }

View file

@ -16,27 +16,27 @@
import * as network from './network'; import * as network from './network';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import { APIRequestContext } from './fetch'; import type { APIRequestContext } from './fetch';
import { assert } from '../utils'; import { assert } from '../utils';
import type { Page } from './page'; import type { Page } from './page';
import { Events } from './events'; import type { Playwright } from './playwright';
export class MockingProxy extends ChannelOwner<channels.MockingProxyChannel> { export class MockingProxy extends ChannelOwner<channels.MockingProxyChannel> {
private _pages = new Map<string, Page>(); _requestContext!: APIRequestContext;
_playwright!: Playwright;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.MockingProxyInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.MockingProxyInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
const requestContext = APIRequestContext.from(initializer.requestContext);
this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => { this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => {
const route = network.Route.from(params.route); const route = network.Route.from(params.route);
route._apiRequestContext = requestContext; route._apiRequestContext = this._requestContext;
const page = route.request()._pageForMockingProxy!; const page = route.request()._pageForMockingProxy!;
await page._onRoute(route); await page._onRoute(route);
}); });
this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => { this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => {
const page = this._pages.get(params.correlation); const page = this.findPage(params.correlation);
assert(page); assert(page);
const request = network.Request.from(params.request); const request = network.Request.from(params.request);
request._pageForMockingProxy = page; request._pageForMockingProxy = page;
@ -68,16 +68,23 @@ export class MockingProxy extends ChannelOwner<channels.MockingProxyChannel> {
return (channel as any)._object; return (channel as any)._object;
} }
async instrumentPage(page: Page) { findPage(correlation: string): Page | undefined {
const correlation = page._guid.split('@')[1]; const guid = `Page@${correlation}`;
this._pages.set(correlation, page); // TODO: move this as list onto Playwright directly
page.on(Events.Page.Close, () => { for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) {
this._pages.delete(correlation); for (const context of browserType._contexts) {
}); for (const page of context._pages) {
const proxyUrl = `http://localhost:${this._initializer.port}/pw_meta:${correlation}/`; if (page._guid === guid)
await page.setExtraHTTPHeaders({ return page;
'x-playwright-proxy': encodeURIComponent(proxyUrl) }
}); }
}
} }
instrumentationHeaders(page: Page) {
const correlation = page._guid.substring('Page@'.length);
return {
'x-playwright-proxy': `${this._initializer.baseURL}/pw_meta:${correlation}/`,
};
}
} }

View file

@ -36,6 +36,7 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
selectors: Selectors; selectors: Selectors;
readonly request: APIRequest; readonly request: APIRequest;
readonly errors: { TimeoutError: typeof TimeoutError }; readonly errors: { TimeoutError: typeof TimeoutError };
_mockingProxy?: MockingProxy;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
@ -77,7 +78,10 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
async _startMockingProxy() { async _startMockingProxy() {
const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel); const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel);
const { mockingProxy } = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel }); const result = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel });
return MockingProxy.from(mockingProxy); this._mockingProxy = MockingProxy.from(result.mockingProxy);
this._mockingProxy._requestContext = requestContext;
this._mockingProxy._playwright = this;
return this._mockingProxy;
} }
} }

View file

@ -377,8 +377,7 @@ scheme.LocalUtilsNewMockingProxyResult = tObject({
mockingProxy: tChannel(['MockingProxy']), mockingProxy: tChannel(['MockingProxy']),
}); });
scheme.MockingProxyInitializer = tObject({ scheme.MockingProxyInitializer = tObject({
port: tNumber, baseURL: tString,
requestContext: tChannel(['APIRequestContext']),
}); });
scheme.MockingProxyRouteEvent = tObject({ scheme.MockingProxyRouteEvent = tObject({
route: tChannel(['Route']), route: tChannel(['Route']),

View file

@ -289,7 +289,7 @@ export class LocalUtilsDispatcher extends Dispatcher<SdkObject, channels.LocalUt
const requestContext = (params.requestContext as APIRequestContextDispatcher)._object; const requestContext = (params.requestContext as APIRequestContextDispatcher)._object;
const mockingProxy = new MockingProxy(this._object, requestContext); const mockingProxy = new MockingProxy(this._object, requestContext);
await mockingProxy.start(); await mockingProxy.start();
return { mockingProxy: MockingProxyDispatcher.from(this.parentScope(), mockingProxy) }; return { mockingProxy: new MockingProxyDispatcher(this, mockingProxy) };
} }
} }

View file

@ -14,30 +14,25 @@
* limitations under the License. * limitations under the License.
*/ */
import { MockingProxy } from '../mockingProxy'; import { MockingProxy } from '../mockingProxy';
import type { RootDispatcher } from './dispatcher'; import { Dispatcher } from './dispatcher';
import { Dispatcher, existingDispatcher } from './dispatcher';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { APIRequestContextDispatcher, RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers'; import { RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
import type { Request, Route } from '../network'; import type { Request } from '../network';
import type { LocalUtilsDispatcher } from './localUtilsDispatcher';
export class MockingProxyDispatcher extends Dispatcher<MockingProxy, channels.MockingProxyChannel, RootDispatcher> implements channels.MockingProxyChannel { export class MockingProxyDispatcher extends Dispatcher<MockingProxy, channels.MockingProxyChannel, LocalUtilsDispatcher> implements channels.MockingProxyChannel {
_type_MockingProxy = true; _type_MockingProxy = true;
_type_EventTarget = true; _type_EventTarget = true;
static from(scope: RootDispatcher, mockingProxy: MockingProxy): MockingProxyDispatcher { constructor(scope: LocalUtilsDispatcher, mockingProxy: MockingProxy) {
return existingDispatcher<MockingProxyDispatcher>(mockingProxy) || new MockingProxyDispatcher(scope, mockingProxy);
}
private constructor(scope: RootDispatcher, mockingProxy: MockingProxy) {
super(scope, mockingProxy, 'MockingProxy', { super(scope, mockingProxy, 'MockingProxy', {
port: mockingProxy.port(), baseURL: mockingProxy.baseURL(),
requestContext: APIRequestContextDispatcher.from(scope, mockingProxy.fetchRequest),
}); });
this.addObjectListener(MockingProxy.Events.Route, (route: Route) => { mockingProxy.onRoute = async route => {
const requestDispatcher = RequestDispatcher.from(this, route.request()); const requestDispatcher = RequestDispatcher.from(this, route.request());
this._dispatchEvent('route', { route: RouteDispatcher.from(requestDispatcher, route) }); this._dispatchEvent('route', { route: RouteDispatcher.from(requestDispatcher, route) });
}); };
this.addObjectListener(MockingProxy.Events.Request, ({ request, correlation }: { request: Request, correlation: string }) => { this.addObjectListener(MockingProxy.Events.Request, ({ request, correlation }: { request: Request, correlation: string }) => {
this._dispatchEvent('request', { request: RequestDispatcher.from(this, request), correlation }); this._dispatchEvent('request', { request: RequestDispatcher.from(this, request), correlation });
}); });

View file

@ -32,13 +32,13 @@ export class MockingProxy extends SdkObject implements RequestContext {
static Events = { static Events = {
Request: 'request', Request: 'request',
Response: 'response', Response: 'response',
Route: 'route',
RequestFailed: 'requestfailed', RequestFailed: 'requestfailed',
RequestFinished: 'requestfinished', RequestFinished: 'requestfinished',
}; };
fetchRequest: APIRequestContext; fetchRequest: APIRequestContext;
private _httpServer = new WorkerHttpServer(); private _httpServer = new WorkerHttpServer();
onRoute = (route: Route) => route.continue({ isFallback: true });
constructor(parent: SdkObject, requestContext: APIRequestContext) { constructor(parent: SdkObject, requestContext: APIRequestContext) {
super(parent, 'MockingProxy'); super(parent, 'MockingProxy');
@ -66,6 +66,10 @@ export class MockingProxy extends SdkObject implements RequestContext {
return this._httpServer.port(); return this._httpServer.port();
} }
baseURL() {
return `http://localhost:${this.port()}/`;
}
private async _proxy(req: http.IncomingMessage, res: http.ServerResponse) { private async _proxy(req: http.IncomingMessage, res: http.ServerResponse) {
if (req.url?.startsWith('/')) if (req.url?.startsWith('/'))
req.url = req.url.substring(1); req.url = req.url.substring(1);
@ -211,7 +215,7 @@ export class MockingProxy extends SdkObject implements RequestContext {
}, },
}); });
this.emit(MockingProxy.Events.Route, route); await this.onRoute(route);
} }
addRouteInFlight(route: Route): void { addRouteInFlight(route: Route): void {

View file

@ -88,7 +88,7 @@ export function stripFragmentFromUrl(url: string): string {
} }
export interface RequestContext extends SdkObject { export interface RequestContext extends SdkObject {
fetchRequest: APIRequestContext; readonly fetchRequest: APIRequestContext;
addRouteInFlight(route: Route): void; addRouteInFlight(route: Route): void;
removeRouteInFlight(route: Route): void; removeRouteInFlight(route: Route): void;

View file

@ -647,8 +647,7 @@ export interface LocalUtilsEvents {
// ----------- MockingProxy ----------- // ----------- MockingProxy -----------
export type MockingProxyInitializer = { export type MockingProxyInitializer = {
port: number, baseURL: string,
requestContext: APIRequestContextChannel,
}; };
export interface MockingProxyEventTarget { export interface MockingProxyEventTarget {
on(event: 'route', callback: (params: MockingProxyRouteEvent) => void): this; on(event: 'route', callback: (params: MockingProxyRouteEvent) => void): this;

View file

@ -737,8 +737,7 @@ MockingProxy:
extends: EventTarget extends: EventTarget
initializer: initializer:
port: number baseURL: string
requestContext: APIRequestContext
events: events:
route: route: