diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 165b9f3bd6..ab286b3163 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -120,6 +120,18 @@ with sync_playwright() as p: assert await response.body() == '{"status": "ok"}' ``` +## event: APIRequestContext.apiRequest +- argument: <[APIRequestEvent]> + +Emitted when a request is issued from any pages created through this context. +The [APIRequestEvent] object is read-only. + +## event: APIRequestContext.apiRequestFinished +- argument: <[APIRequestFinishedEvent]> + +Emitted when a request finishes successfully after downloading the response body. For a successful response, the +sequence of events is `request` and `requestfinished`. + ## method: APIRequestContext.createFormData * since: v1.23 * langs: csharp diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index a074b26f3d..de5b0a67a6 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -50,6 +50,11 @@ export const Events = { RequestFinished: 'requestfinished', }, + APIRequestContext: { + APIRequest: 'apiRequest', + APIRequestFinished: 'apiRequestfinished', + }, + BrowserServer: { Close: 'close', }, diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index dada11ed30..72c8c1900f 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { APIRequestEvent, APIRequestFinishedEvent } from 'playwright-core/lib/server/fetch'; + import { toClientCertificatesProtocol } from './browserContext'; import { ChannelOwner } from './channelOwner'; import { TargetClosedError, isTargetClosedError } from './errors'; @@ -23,6 +25,7 @@ import { assert } from '../utils/isomorphic/assert'; import { mkdirIfNeeded } from './fileUtils'; import { headersObjectToArray } from '../utils/isomorphic/headers'; import { isString } from '../utils/isomorphic/rtti'; +import { Events } from './events'; import type { Playwright } from './playwright'; import type { ClientCertificate, FilePayload, Headers, SetStorageState, StorageState } from './types'; @@ -98,7 +101,11 @@ export class APIRequestContext extends ChannelOwner { + this._onRequest(request); + }); + this._channel.on('apiRequestFinished', params => this._onRequestFinished(params)); } async [Symbol.asyncDispose]() { @@ -269,6 +276,14 @@ export class APIRequestContext extends ChannelOwner { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index bd36e905b4..0ef6abd4cc 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -16,7 +16,7 @@ // This file is generated by generate_channels.js, do not edit manually. -import { scheme, tOptional, tObject, tBoolean, tNumber, tString, tAny, tEnum, tArray, tBinary, tChannel, tType } from './validatorPrimitives'; +import { scheme, tOptional, tObject, tBoolean, tNumber, tString, tAny, tEnum, tArray, tBinary, tChannel, tType, tURL } from './validatorPrimitives'; export type { Validator, ValidatorContext } from './validatorPrimitives'; export { ValidationError, findValidator, maybeFindValidator, createMetadataValidator } from './validatorPrimitives'; @@ -201,7 +201,44 @@ scheme.FormField = tObject({ })), }); scheme.APIRequestContextInitializer = tObject({ - tracing: tChannel(['Tracing']), + tracing: tOptional(tChannel(['Tracing'])), +}); +scheme.APIRequestContextApiRequestEvent = tObject({ + guid: tString, + url: tURL, + method: tString, + headers: tAny, + cookies: tArray(tType('NameValue')), + postData: tOptional(tBinary), +}); +scheme.APIRequestContextApiRequestFinishedEvent = tObject({ + requestEvent: tObject({ + guid: tString, + url: tURL, + method: tString, + headers: tAny, + cookies: tArray(tType('NameValue')), + }), + httpVersion: tString, + headers: tAny, + cookies: tArray(tType('NetworkCookie')), + rawHeaders: tArray(tString), + statusCode: tNumber, + statusMessage: tString, + body: tOptional(tBinary), + timings: tObject({ + blocked: tOptional(tNumber), + dns: tOptional(tNumber), + connect: tOptional(tNumber), + send: tNumber, + wait: tNumber, + receive: tNumber, + ssl: tOptional(tNumber), + comment: tOptional(tString), + }), + serverIPAddress: tOptional(tString), + serverPort: tOptional(tNumber), + securityDetails: tOptional(tType('SecurityDetails')), }); scheme.APIRequestContextFetchParams = tObject({ url: tString, diff --git a/packages/playwright-core/src/protocol/validatorPrimitives.ts b/packages/playwright-core/src/protocol/validatorPrimitives.ts index eadd2e014e..c49bddad21 100644 --- a/packages/playwright-core/src/protocol/validatorPrimitives.ts +++ b/packages/playwright-core/src/protocol/validatorPrimitives.ts @@ -128,6 +128,13 @@ export const tEnum = (e: string[]): Validator => { return arg; }; }; +export const tURL = (arg: any, path: string, context: ValidatorContext) => { + if (arg instanceof URL) + return arg; + if (typeof arg === 'string') + return new URL(arg); + throw new ValidationError(`${path}: expected URL, got ${typeof arg}`); +}; export const tChannel = (names: '*' | string[]): Validator => { return (arg: any, path: string, context: ValidatorContext) => { return context.tChannelImpl(names, arg, path, context); diff --git a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts index c6c783f390..1e10a559a9 100644 --- a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts +++ b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts @@ -19,11 +19,11 @@ import { Dispatcher, existingDispatcher } from './dispatcher'; import { FrameDispatcher } from './frameDispatcher'; import { WorkerDispatcher } from './pageDispatcher'; import { TracingDispatcher } from './tracingDispatcher'; +import { APIRequestContext, APIRequestEvent, APIRequestFinishedEvent } from '../fetch'; +import { BrowserContextDispatcher } from './browserContextDispatcher'; -import type { APIRequestContext } from '../fetch'; import type { CallMetadata } from '../instrumentation'; import type { Request, Response, Route } from '../network'; -import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { RootDispatcher } from './dispatcher'; import type { PageDispatcher } from './pageDispatcher'; import type * as channels from '@protocol/channels'; @@ -193,9 +193,17 @@ export class APIRequestContextDispatcher extends Dispatcher { + this._dispatchEvent('apiRequest', request); + }); + this.addObjectListener(APIRequestContext.Events.RequestFinished, (request: APIRequestFinishedEvent) => { + this._dispatchEvent('apiRequestFinished', request); + }); } + async storageState(params: channels.APIRequestContextStorageStateParams): Promise { return this._object.storageState(params.indexedDB); } diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 984d5e3067..3a3279bc72 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -63,6 +63,7 @@ type FetchRequestOptions = { type HeadersObject = Readonly<{ [name: string]: string }>; export type APIRequestEvent = { + guid: string, url: URL, method: string, headers: HeadersObject, @@ -294,6 +295,7 @@ export abstract class APIRequestContext extends SdkObject { return { name, value }; }) || []; const requestEvent: APIRequestEvent = { + guid: createGuid(), url, method: options.method!, headers: options.headers, diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 874f072035..738bffd770 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19,6 +19,7 @@ import { Readable } from 'stream'; import { ReadStream } from 'fs'; import { Protocol } from './protocol'; import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource } from './structs'; +import {APIRequestEvent, APIRequestFinishedEvent} from "playwright-core/lib/server/fetch"; type PageWaitForSelectorOptionsNotHidden = PageWaitForSelectorOptions & { state?: 'visible'|'attached'; @@ -18665,6 +18666,18 @@ export interface APIRequestContext { }>; [Symbol.asyncDispose](): Promise; + + + /** + * Emitted when a request is issued from API request context. The event will be emitted after the request is issued + */ + on(event: 'apiRequest', listener: (request: APIRequestEvent) => any): this; + + /** + * Emitted when a request finishes successfully after downloading the response body. For a successful response, the + * sequence of events is `request`, `response` and `requestfinished`. + */ + on(event: 'apiRequestfinished', listener: (request: APIRequestFinishedEvent) => any): this; } /** diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 117f0e287e..879c2391e8 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -337,9 +337,11 @@ export type FormField = { // ----------- APIRequestContext ----------- export type APIRequestContextInitializer = { - tracing: TracingChannel, + tracing?: TracingChannel, }; export interface APIRequestContextEventTarget { + on(event: 'apiRequest', callback: (params: APIRequestContextApiRequestEvent) => void): this; + on(event: 'apiRequestFinished', callback: (params: APIRequestContextApiRequestFinishedEvent) => void): this; } export interface APIRequestContextChannel extends APIRequestContextEventTarget, Channel { _type_APIRequestContext: boolean; @@ -350,6 +352,43 @@ export interface APIRequestContextChannel extends APIRequestContextEventTarget, disposeAPIResponse(params: APIRequestContextDisposeAPIResponseParams, metadata?: CallMetadata): Promise; dispose(params: APIRequestContextDisposeParams, metadata?: CallMetadata): Promise; } +export type APIRequestContextApiRequestEvent = { + guid: string, + url: URL, + method: string, + headers: any, + cookies: NameValue[], + postData?: Binary, +}; +export type APIRequestContextApiRequestFinishedEvent = { + requestEvent: { + guid: string, + url: URL, + method: string, + headers: any, + cookies: NameValue[], + }, + httpVersion: string, + headers: any, + cookies: NetworkCookie[], + rawHeaders: string[], + statusCode: number, + statusMessage: string, + body?: Binary, + timings: { + blocked?: number, + dns?: number, + connect?: number, + send: number, + wait: number, + receive: number, + ssl?: number, + comment?: string, + }, + serverIPAddress?: string, + serverPort?: number, + securityDetails?: SecurityDetails, +}; export type APIRequestContextFetchParams = { url: string, encodedParams?: string, @@ -428,6 +467,8 @@ export type APIRequestContextDisposeOptions = { export type APIRequestContextDisposeResult = void; export interface APIRequestContextEvents { + 'apiRequest': APIRequestContextApiRequestEvent; + 'apiRequestFinished': APIRequestContextApiRequestFinishedEvent; } export type APIResponse = { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 1044f8ccb2..9ac9973adf 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -313,7 +313,6 @@ RecordHarOptions: urlRegexSource: string? urlRegexFlags: string? - FormField: type: object properties: @@ -330,7 +329,7 @@ APIRequestContext: type: interface initializer: - tracing: Tracing + tracing: Tracing? commands: @@ -394,6 +393,54 @@ APIRequestContext: parameters: reason: string? + events: + apiRequest: + parameters: + guid: string + url: URL + method: string + headers: json + cookies: + type: array + items: NameValue + postData: binary? + apiRequestFinished: + parameters: + requestEvent: + type: object + properties: + guid: string + url: URL + method: string + headers: json + cookies: + type: array + items: NameValue + httpVersion: string + headers: json + cookies: + type: array + items: NetworkCookie + rawHeaders: + type: array + items: string + statusCode: number + statusMessage: string + body: binary? + timings: + type: object + properties: + blocked: number? + dns: number? + connect: number? + send: number + wait: number + receive: number + ssl: number? + comment: string? + serverIPAddress: string? + serverPort: number? + securityDetails: SecurityDetails? APIResponse: type: object diff --git a/tests/library/apirequestcontext-network-event.spec.ts b/tests/library/apirequestcontext-network-event.spec.ts new file mode 100644 index 0000000000..6f8c6f1da6 --- /dev/null +++ b/tests/library/apirequestcontext-network-event.spec.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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 { browserTest as it, expect } from '../config/browserTest'; +import { APIRequestEvent, APIRequestFinishedEvent } from 'playwright-core/src/server/fetch'; + +it('APIRequestContext.Events.Request', async ({ context, server }) => { + const requests: APIRequestEvent[] = []; + context.request.on('apiRequest', request => { + requests.push(request); + }); + await context.request.fetch(server.EMPTY_PAGE); + + await setTimeout(() => {}, 100); + + const urls = requests.map(r => r.url.toString()); + expect(urls).toEqual([ + server.EMPTY_PAGE, + ]); +}); + + +it('APIRequestContext.Events.RequestFinished', async ({ context, server }) => { + + const finishedRequests: APIRequestFinishedEvent[] = []; + + context.request.on('apiRequestfinished', request => finishedRequests.push(request)); + await context.request.fetch(server.EMPTY_PAGE); + + const request = finishedRequests[0]; + + expect(request.requestEvent.url.toString()).toBe(server.EMPTY_PAGE); + expect(request.timings.send).toBeTruthy(); +}); + +it('should fire events in proper order', async ({ context, server }) => { + const events: string[] = []; + context.request.on('apiRequest', () => events.push('apiRequest')); + context.request.on('apiRequestfinished', () => events.push('apiRequestfinished')); + await context.request.fetch(server.EMPTY_PAGE); + expect(events).toEqual([ + 'apiRequest', + 'apiRequestfinished' + ]); +}); diff --git a/utils/generate_channels.js b/utils/generate_channels.js index 827250ad8c..6270115d0c 100755 --- a/utils/generate_channels.js +++ b/utils/generate_channels.js @@ -51,6 +51,9 @@ function inlineType(type, indent, wrapEnums = false) { } if (type === 'Channel') return { ts: `Channel`, scheme: `tChannel('*')`, optional }; + if(type === 'URL'){ + return { ts: 'URL', scheme: `tURL`, optional }; + } return { ts: type, scheme: `tType('${type}')`, optional }; } if (type.type.startsWith('array')) { @@ -153,7 +156,7 @@ const validator_ts = [ // This file is generated by ${path.basename(__filename)}, do not edit manually. -import { scheme, tOptional, tObject, tBoolean, tNumber, tString, tAny, tEnum, tArray, tBinary, tChannel, tType } from './validatorPrimitives'; +import { scheme, tOptional, tObject, tBoolean, tNumber, tString, tAny, tEnum, tArray, tBinary, tChannel, tType, tURL } from './validatorPrimitives'; export type { Validator, ValidatorContext } from './validatorPrimitives'; export { ValidationError, findValidator, maybeFindValidator, createMetadataValidator } from './validatorPrimitives'; `];