diff --git a/packages/playwright-core/src/protocol/debug.ts b/packages/playwright-core/src/protocol/debug.ts index 176864f4f5..64a442450b 100644 --- a/packages/playwright-core/src/protocol/debug.ts +++ b/packages/playwright-core/src/protocol/debug.ts @@ -66,7 +66,6 @@ export const slowMoActions = new Set([ export const commandsWithTracingSnapshots = new Set([ 'EventTarget.waitForEventInfo', - 'LocalUtils.waitForEventInfo', 'MockingProxy.waitForEventInfo', 'BrowserContext.waitForEventInfo', 'Page.waitForEventInfo', diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 661c4fc9d8..def91fc000 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -236,7 +236,6 @@ scheme.EventTargetWaitForEventInfoParams = tObject({ error: tOptional(tString), }), }); -scheme.LocalUtilsWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.MockingProxyWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.BrowserContextWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.PageWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); @@ -244,7 +243,6 @@ scheme.WebSocketWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParam scheme.ElectronApplicationWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.AndroidDeviceWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({})); -scheme.LocalUtilsWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.MockingProxyWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.BrowserContextWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.PageWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 8a4af139af..1dcc791155 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -20,7 +20,7 @@ import path from 'path'; import os from 'os'; import type * as channels from '@protocol/channels'; import { ManualPromise } from '../../utils/manualPromise'; -import { assert, calculateSha1, createGuid, HttpServer, removeFolders, urlMatches } from '../../utils'; +import { assert, calculateSha1, createGuid, removeFolders } from '../../utils'; import type { RootDispatcher } from './dispatcher'; import { Dispatcher } from './dispatcher'; import { yazl, yauzl } from '../../zipBundle'; @@ -41,14 +41,13 @@ import type { Playwright } from '../playwright'; import { SdkObject } from '../../server/instrumentation'; import { serializeClientSideCallMetadata } from '../../utils'; import { deviceDescriptors as descriptors } from '../deviceDescriptors'; -import { APIRequestContextDispatcher, RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers'; import type { APIRequestContext } from '../fetch'; import { GlobalAPIRequestContext } from '../fetch'; -import { MockingProxy, ServerInterceptionRegistry } from '../mockingProxy'; +import { MockingProxy } from '../mockingProxy'; +import { MockingProxyDispatcher } from './mockingProxyDispatcher'; export class LocalUtilsDispatcher extends Dispatcher implements channels.LocalUtilsChannel { _type_LocalUtils: boolean; - _type_EventTarget: boolean; private _harBackends = new Map(); private _stackSessions = new Map(); - _requestContext: APIRequestContext; - private _interceptionRegistry; - private _server?: WorkerHttpServer; + private _requestContext: APIRequestContext; constructor(scope: RootDispatcher, playwright: Playwright) { const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils'); @@ -68,40 +65,9 @@ export class LocalUtilsDispatcher extends Dispatcher { - this._dispatchEvent('request', { request: RequestDispatcher.from(this.parentScope() as any, request) }); - }, - onRequestFinished: (request, response) => { - this._dispatchEvent('requestFinished', { - request: RequestDispatcher.from(this.parentScope() as any, request), - response: ResponseDispatcher.fromNullable(this.parentScope() as any, response ?? null), - responseEndTiming: request._responseEndTiming, - }); - }, - onRequestFailed: request => { - this._dispatchEvent('requestFailed', { - request: RequestDispatcher.from(this.parentScope() as any, request), - responseEndTiming: request._responseEndTiming, - failureText: request._failureText ?? undefined - }); - }, - onResponse: (request, response) => { - this._dispatchEvent('response', { - request: RequestDispatcher.from(this.parentScope() as any, request), - response: ResponseDispatcher.from(this.parentScope() as any, response), - }); - }, - onRoute: (route, request) => { - this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this.parentScope() as any, request), route) }); - }, - }); } async zip(params: channels.LocalUtilsZipParams): Promise { @@ -316,24 +282,10 @@ export class LocalUtilsDispatcher extends Dispatcher { - if (!this._server) { - this._server = new WorkerHttpServer(); - new MockingProxy(this._interceptionRegistry).install(this._server); - await this._server.start({ port: params.port }); - } - - if (params.patterns.length === 0) - return this._interceptionRegistry.setRequestInterceptor(undefined); - - const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!); - this._interceptionRegistry.setRequestInterceptor(url => urlMatchers.some(urlMatch => urlMatches(undefined, url, urlMatch))); - } -} - -export class WorkerHttpServer extends HttpServer { - override _handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean { - return false; + async newMockingProxy(params: channels.LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise { + const mockingProxy = new MockingProxy(this._object, this._requestContext); + await mockingProxy.start(params.port); + return { mockingProxy: MockingProxyDispatcher.from(this.parentScope(), mockingProxy) }; } } diff --git a/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts index 6bcef53e84..399be0e89f 100644 --- a/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts @@ -14,27 +14,33 @@ * limitations under the License. */ import type { CallMetadata } from '@protocol/callMetadata'; -import type { MockingProxy, ServerInterceptionRegistry } from '../mockingProxy'; +import type { MockingProxy } from '../mockingProxy'; import type { RootDispatcher } from './dispatcher'; import { Dispatcher, existingDispatcher } from './dispatcher'; import type * as channels from '@protocol/channels'; -import { SdkObject } from '../instrumentation'; +import { APIRequestContextDispatcher } from './networkDispatchers'; +import { urlMatches } from '@isomorphic/urlMatch'; -export class MockingProxyDispatcher extends Dispatcher implements channels.MockingProxyChannel { +export class MockingProxyDispatcher extends Dispatcher implements channels.MockingProxyChannel { _type_MockingProxy = true; _type_EventTarget = true; - static from(scope: RootDispatcher, mockingProxy: ServerInterceptionRegistry): MockingProxyDispatcher { + static from(scope: RootDispatcher, mockingProxy: MockingProxy): MockingProxyDispatcher { return existingDispatcher(mockingProxy) || new MockingProxyDispatcher(scope, mockingProxy); } - private constructor(scope: RootDispatcher, mockingProxy: ServerInterceptionRegistry) { + private constructor(scope: RootDispatcher, mockingProxy: MockingProxy) { super(scope, mockingProxy, 'MockingProxy', { - port: mockingProxy.port(), + port: mockingProxy.port, + requestContext: APIRequestContextDispatcher.from(scope, mockingProxy.fetchRequest), }); } - setInterceptionPatterns(params: channels.MockingProxySetInterceptionPatternsParams, metadata?: CallMetadata): Promise { - throw new Error('Method not implemented.'); + async setInterceptionPatterns(params: channels.MockingProxySetInterceptionPatternsParams, metadata?: CallMetadata): Promise { + if (params.patterns.length === 0) + return this._object.setInterceptionPatterns(undefined); + + const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!); + this._object.setInterceptionPatterns(url => urlMatchers.some(urlMatch => urlMatches(undefined, url, urlMatch))); } } diff --git a/packages/playwright-core/src/server/mockingProxy.ts b/packages/playwright-core/src/server/mockingProxy.ts index 108b75c05f..c7a951aa11 100644 --- a/packages/playwright-core/src/server/mockingProxy.ts +++ b/packages/playwright-core/src/server/mockingProxy.ts @@ -19,144 +19,45 @@ import https from 'https'; import url from 'url'; import type { APIRequestContext } from './fetch'; import { SdkObject } from './instrumentation'; -import type { RemoteAddr, RequestContext, ResourceTiming, SecurityDetails } from './network'; +import type { RequestContext, ResourceTiming, SecurityDetails } from './network'; import { Request, Response, Route } from './network'; -import type { HeadersArray, NormalizedContinueOverrides, NormalizedFulfillResponse } from './types'; -import { ManualPromise, monotonicTime } from 'playwright-core/lib/utils'; -import type { WorkerHttpServer } from './dispatchers/localUtilsDispatcher'; +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'; -type InterceptorResult = -| { result: 'continue', request: Request, overrides?: NormalizedContinueOverrides } -| { result: 'abort', request: Request, errorCode: string } -| { result: 'fulfill', request: Request, response: NormalizedFulfillResponse }; - -interface EventDelegate { - onRequest(request: Request): void; - onRequestFinished(request: Request, response: Response): void; - onRequestFailed(request: Request): void; - onResponse(request: Request, response: Response): void; - onRoute(route: Route, request: Request): void; -} - -export class ServerInterceptionRegistry extends SdkObject implements RequestContext { - private _eventDelegate: EventDelegate; +export class MockingProxy extends SdkObject implements RequestContext { fetchRequest: APIRequestContext; private _matches?: (url: string) => boolean; + private _httpServer = new WorkerHttpServer(); - constructor(parent: SdkObject, requestContext: APIRequestContext, eventDelegate: EventDelegate) { - super(parent, 'serverInterceptionRegistry'); - this._eventDelegate = eventDelegate; + constructor(parent: SdkObject, requestContext: APIRequestContext) { + super(parent, 'MockingProxy'); this.fetchRequest = requestContext; - } - setRequestInterceptor(matches?: (url: string) => boolean) { - this._matches = matches; - } - - handle(url: string, method: string, body: Buffer | null, headers: HeadersArray): Promise { - const request = new Request(this, null, null, null, undefined, url, '', method, body, headers); - request.setRawRequestHeaders(headers); - this._eventDelegate.onRequest(request); - - if (!this._matches?.(url)) - return Promise.resolve({ result: 'continue', request }); - - return new Promise(resolve => { - const route = new Route(request, { - async abort(errorCode) { - resolve({ result: 'abort', request, errorCode }); - }, - async continue(overrides) { - resolve({ result: 'continue', request, overrides }); - }, - async fulfill(response) { - resolve({ result: 'fulfill', request, response }); - }, - }); - - this._eventDelegate.onRoute(route, request); - }); - } - - failed(request: Request, error: string) { - request._setFailureText(error); - this._eventDelegate.onRequestFailed(request); - } - - response(request: Request, status: number, statusText: string, headers: HeadersArray, body: () => Promise, httpVersion: string, timing: ResourceTiming, securityDetails: SecurityDetails | undefined, serverAddr: RemoteAddr | undefined) { - const response = new Response(request, status, statusText, headers, timing, body, false, httpVersion); - response.setRawResponseHeaders(headers); - response._securityDetailsFinished(securityDetails); - response._serverAddrFinished(serverAddr); - this._eventDelegate.onResponse(request, response); - - return { - finished: async (responseEndTiming: number, transferSize: number, encodedBodySize: number) => { - response._requestFinished(responseEndTiming); - response.setTransferSize(transferSize); - response.setEncodedBodySize(encodedBodySize); - response.setResponseHeadersSize(transferSize - encodedBodySize); - this._eventDelegate.onRequestFinished(request, response); - } - }; - } - - addRouteInFlight(route: Route): void { - - } - - removeRouteInFlight(route: Route): void { - - } -} - -function headersArray(req: Pick): 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((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', chunk => chunks.push(chunk)); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); - }); -} - -export class MockingProxy { - private readonly _registry: ServerInterceptionRegistry; - - constructor(registry: ServerInterceptionRegistry) { - this._registry = registry; - } - - install(server: WorkerHttpServer) { - server.routePrefix('/', (req, res) => { + this._httpServer.routePrefix('/', (req, res) => { this._proxy(req, res); return true; }); - server.server().on('connect', (req, socket, head) => { + this._httpServer.server().on('connect', (req, socket, head) => { socket.end('HTTP/1.1 405 Method Not Allowed\r\n\r\n'); }); } + async start(port?: number): Promise { + await this._httpServer.start({ port }); + } + + get port() { + return this._httpServer.port(); + } + + setInterceptionPatterns(matches?: (url: string) => boolean) { + this._matches = matches; + } + private async _proxy(req: http.IncomingMessage, res: http.ServerResponse) { if (req.url?.startsWith('/')) req.url = req.url.substring(1); @@ -170,14 +71,14 @@ export class MockingProxy { delete req.headersDistinct.host; const headers = headersArray(req); const body = await collectBody(req); - const result = await this._registry.handle(req.url!, req.method!, body, headers); - switch (result.result) { - case 'abort': { - req.destroy(result.errorCode ? new Error(result.errorCode) : undefined); - return; - } - case 'continue': { - const { overrides } = result; + const request = new Request(this, null, null, null, undefined, req.url!, '', req.method!, body, headers); + request.setRawRequestHeaders(headers); + + 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; @@ -225,16 +126,11 @@ export class MockingProxy { const address = socket.address() as AddressInfo; const responseBodyPromise = new ManualPromise(); - const response = this._registry.response( - result.request, - proxyRes.statusCode!, - proxyRes.statusMessage!, headersArray(proxyRes), - () => responseBodyPromise, - proxyRes.httpVersion, - timings, - securityDetails, - { ipAddress: address.family === 'IPv6' ? `[${address.address}]` : address.address, port: address.port }, - ); + const response = new Response(request, proxyRes.statusCode!, proxyRes.statusMessage!, headersArray(proxyRes), timings, () => responseBodyPromise, false, proxyRes.httpVersion); + response.setRawResponseHeaders(headers); + response._securityDetailsFinished(securityDetails); + response._serverAddrFinished({ ipAddress: address.family === 'IPv6' ? `[${address.address}]` : address.address, port: address.port }); + this.emit('response', response); try { res.writeHead(proxyRes.statusCode!, proxyRes.headers); @@ -253,20 +149,24 @@ export class MockingProxy { const body = Buffer.concat(chunks); responseBodyPromise.resolve(body); - response.finished( - monotonicTime() - startAt, - socket.bytesRead - socketBytesReadStart, - body.byteLength - ); + 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('requestFinished', response); resolve(); } catch (error) { - this._registry.failed(result.request, error.toString()); + request._setFailureText('' + error); + this.emit('failed', request); resolve(); } }); proxyReq.on('error', error => { - this._registry.failed(result.request, error.toString()); + request._setFailureText('' + error); + this.emit('failed', request); res.statusCode = 502; res.end(resolve); }); @@ -283,19 +183,51 @@ export class MockingProxy { }); proxyReq.end(proxyBody); }); - } - case 'fulfill': { - const { response: { status, headers, body, isBase64 } } = result; + }, + 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')); - return; - } - default: { - throw new Error('Unexpected result'); - } - } + }, + }); + + if (this._matches?.(req.url!)) + this.emit('route', { route, request }); + else + await route.continue({ isFallback: false }); + } +} + +function headersArray(req: Pick): 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((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +export class WorkerHttpServer extends HttpServer { + override _handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean { + return false; } } diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index f3c09d9aa8..b4f5020093 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -89,10 +89,10 @@ export function stripFragmentFromUrl(url: string): string { } export interface RequestContext extends SdkObject { - addRouteInFlight(route: Route): void; - removeRouteInFlight(route: Route): void; - fetchRequest: APIRequestContext; + + addRouteInFlight?(route: Route): void; + removeRouteInFlight?(route: Route): void; } export class Request extends SdkObject { @@ -261,7 +261,7 @@ export class Route extends SdkObject { super(request._frame || request._context, 'route'); this._request = request; this._delegate = delegate; - this._request._context.addRouteInFlight(this); + this._request._context.addRouteInFlight?.(this); } request(): Request { @@ -347,7 +347,7 @@ export class Route extends SdkObject { } private _endHandling() { - this._request._context.removeRouteInFlight(this); + this._request._context.removeRouteInFlight?.(this); } } diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index a7afad3639..5c1863b521 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -455,7 +455,7 @@ export type LocalUtilsInitializer = { }; export interface LocalUtilsEventTarget { } -export interface LocalUtilsChannel extends LocalUtilsEventTarget, EventTargetChannel { +export interface LocalUtilsChannel extends LocalUtilsEventTarget, Channel { _type_LocalUtils: boolean; zip(params: LocalUtilsZipParams, metadata?: CallMetadata): Promise; harOpen(params: LocalUtilsHarOpenParams, metadata?: CallMetadata): Promise; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 629c5ce4f5..839874d24a 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -551,8 +551,6 @@ EventTarget: LocalUtils: type: interface - extends: EventTarget - initializer: deviceDescriptors: type: array