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.
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`.
:::

View file

@ -27,6 +27,7 @@ import { assert, headersObjectToArray, monotonicTime } from '../utils';
import type * as api from '../../types/types';
import { raceAgainstDeadline } from '../utils/timeoutRunner';
import type { Playwright } from './playwright';
import type { Page } from './page';
export interface BrowserServerLauncher {
launchServer(options?: LaunchServerOptions): Promise<api.BrowserServer>;
@ -241,6 +242,14 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
context.setDefaultTimeout(this._defaultContextTimeout);
if (this._defaultContextNavigationTimeout !== undefined)
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);
}

View file

@ -16,27 +16,27 @@
import * as network from './network';
import type * as channels from '@protocol/channels';
import { ChannelOwner } from './channelOwner';
import { APIRequestContext } from './fetch';
import type { APIRequestContext } from './fetch';
import { assert } from '../utils';
import type { Page } from './page';
import { Events } from './events';
import type { Playwright } from './playwright';
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) {
super(parent, type, guid, initializer);
const requestContext = APIRequestContext.from(initializer.requestContext);
this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => {
const route = network.Route.from(params.route);
route._apiRequestContext = requestContext;
route._apiRequestContext = this._requestContext;
const page = route.request()._pageForMockingProxy!;
await page._onRoute(route);
});
this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => {
const page = this._pages.get(params.correlation);
const page = this.findPage(params.correlation);
assert(page);
const request = network.Request.from(params.request);
request._pageForMockingProxy = page;
@ -68,16 +68,23 @@ export class MockingProxy extends ChannelOwner<channels.MockingProxyChannel> {
return (channel as any)._object;
}
async instrumentPage(page: Page) {
const correlation = page._guid.split('@')[1];
this._pages.set(correlation, page);
page.on(Events.Page.Close, () => {
this._pages.delete(correlation);
});
const proxyUrl = `http://localhost:${this._initializer.port}/pw_meta:${correlation}/`;
await page.setExtraHTTPHeaders({
'x-playwright-proxy': encodeURIComponent(proxyUrl)
});
findPage(correlation: string): Page | undefined {
const guid = `Page@${correlation}`;
// TODO: move this as list onto Playwright directly
for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) {
for (const context of browserType._contexts) {
for (const page of context._pages) {
if (page._guid === guid)
return page;
}
}
}
}
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;
readonly request: APIRequest;
readonly errors: { TimeoutError: typeof TimeoutError };
_mockingProxy?: MockingProxy;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
super(parent, type, guid, initializer);
@ -77,7 +78,10 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
async _startMockingProxy() {
const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel);
const { mockingProxy } = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel });
return MockingProxy.from(mockingProxy);
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

@ -377,8 +377,7 @@ scheme.LocalUtilsNewMockingProxyResult = tObject({
mockingProxy: tChannel(['MockingProxy']),
});
scheme.MockingProxyInitializer = tObject({
port: tNumber,
requestContext: tChannel(['APIRequestContext']),
baseURL: tString,
});
scheme.MockingProxyRouteEvent = tObject({
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 mockingProxy = new MockingProxy(this._object, requestContext);
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.
*/
import { MockingProxy } from '../mockingProxy';
import type { RootDispatcher } from './dispatcher';
import { Dispatcher, existingDispatcher } from './dispatcher';
import { Dispatcher } from './dispatcher';
import type * as channels from '@protocol/channels';
import { APIRequestContextDispatcher, RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
import type { Request, Route } from '../network';
import { RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
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_EventTarget = true;
static from(scope: RootDispatcher, mockingProxy: MockingProxy): MockingProxyDispatcher {
return existingDispatcher<MockingProxyDispatcher>(mockingProxy) || new MockingProxyDispatcher(scope, mockingProxy);
}
private constructor(scope: RootDispatcher, mockingProxy: MockingProxy) {
constructor(scope: LocalUtilsDispatcher, mockingProxy: MockingProxy) {
super(scope, mockingProxy, 'MockingProxy', {
port: mockingProxy.port(),
requestContext: APIRequestContextDispatcher.from(scope, mockingProxy.fetchRequest),
baseURL: mockingProxy.baseURL(),
});
this.addObjectListener(MockingProxy.Events.Route, (route: Route) => {
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 });
});

View file

@ -32,13 +32,13 @@ export class MockingProxy extends SdkObject implements RequestContext {
static Events = {
Request: 'request',
Response: 'response',
Route: 'route',
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');
@ -66,6 +66,10 @@ export class MockingProxy extends SdkObject implements RequestContext {
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);
@ -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 {

View file

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

View file

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

View file

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