From 821f5d1a36a3f6f447aa59a6b832f2db019b4f08 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 19 Sep 2025 12:28:13 +0100 Subject: [PATCH] feat: page.requests() Returns up to 100 last network requests. --- docs/src/api/class-page.md | 7 ++++ packages/playwright-client/types/types.d.ts | 6 +++ packages/playwright-core/src/client/page.ts | 9 +++- .../playwright-core/src/protocol/validator.ts | 4 ++ .../src/server/browserContext.ts | 1 + .../dispatchers/browserContextDispatcher.ts | 6 +++ .../server/dispatchers/networkDispatchers.ts | 5 ++- .../src/server/dispatchers/pageDispatcher.ts | 4 ++ packages/playwright-core/src/server/frames.ts | 1 + packages/playwright-core/src/server/page.ts | 16 ++++++- .../src/utils/isomorphic/protocolMetainfo.ts | 1 + packages/playwright-core/types/types.d.ts | 6 +++ packages/protocol/src/channels.d.ts | 6 +++ packages/protocol/src/protocol.yml | 8 ++++ tests/page/page-event-request.spec.ts | 42 +++++++++++++++++++ tests/stress/heap.spec.ts | 12 ++++++ 16 files changed, 128 insertions(+), 6 deletions(-) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 541aebce3c0bc..2a17c0b1ceb92 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3137,6 +3137,13 @@ return value resolves to `[]`. * since: v1.9 +## async method: Page.requests +* since: v1.56 +- returns: <[Array]<[Request]>> + +Returns up to 100 last network request from this page. See [`event: Page.request`] for more details. + + ## async method: Page.addLocatorHandler * since: v1.42 diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 867dfd391ceca..32c68fbc77e92 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -3926,6 +3926,12 @@ export interface Page { */ requestGC(): Promise; + /** + * Returns up to 100 last network request from this page. See + * [page.on('request')](https://playwright.dev/docs/api/class-page#page-event-request) for more details. + */ + requests(): Promise>; + /** * Routing provides the capability to modify network requests that are made by a page. * diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index d838311f14a78..61394f99799d2 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -29,7 +29,7 @@ import { Frame, verifyLoadState } from './frame'; import { HarRouter } from './harRouter'; import { Keyboard, Mouse, Touchscreen } from './input'; import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; -import { Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network'; +import { Request, Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network'; import { Video } from './video'; import { Waiter } from './waiter'; import { Worker } from './worker'; @@ -48,7 +48,7 @@ import type { Clock } from './clock'; import type { APIRequestContext } from './fetch'; import type { WaitForNavigationOptions } from './frame'; import type { FrameLocator, Locator, LocatorOptions } from './locator'; -import type { Request, RouteHandlerCallback, WebSocketRouteHandlerCallback } from './network'; +import type { RouteHandlerCallback, WebSocketRouteHandlerCallback } from './network'; import type { FilePayload, Headers, LifecycleEvent, SelectOption, SelectOptionOptions, Size, TimeoutOptions, WaitForEventOptions, WaitForFunctionOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; @@ -804,6 +804,11 @@ export class Page extends ChannelOwner implements api.Page return await this._mainFrame.waitForFunction(pageFunction, arg, options); } + async requests() { + const { requests } = await this._channel.requests(); + return requests.map(request => Request.from(request)); + } + workers(): Worker[] { return [...this._workers]; } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index f5cb173a42087..f68ee39bc4b13 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1481,6 +1481,10 @@ scheme.PagePdfParams = tObject({ scheme.PagePdfResult = tObject({ pdf: tBinary, }); +scheme.PageRequestsParams = tOptional(tObject({})); +scheme.PageRequestsResult = tObject({ + requests: tArray(tChannel(['Request'])), +}); scheme.PageSnapshotForAIParams = tObject({ timeout: tFloat, }); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 1866e05a21b44..e9b36e61c65ff 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -62,6 +62,7 @@ export abstract class BrowserContext extends SdkObject { RequestAborted: 'requestaborted', RequestFulfilled: 'requestfulfilled', RequestContinued: 'requestcontinued', + RequestCollected: 'requestcollected', BeforeClose: 'beforeclose', VideoStarted: 'videostarted', RecorderEvent: 'recorderevent', diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 8208655bc2bfd..f06787b81260f 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -154,6 +154,7 @@ export class BrowserContextDispatcher extends Dispatcher { + const requestDispatcher = this.connection.existingDispatcher(request); + if (requestDispatcher && !requestDispatcher.reportedThroughEvent) + requestDispatcher._dispose('gc'); + }); this.addObjectListener(BrowserContext.Events.RecorderEvent, ({ event, data, page, code }: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page, code: string }) => { this._dispatchEvent('recorderEvent', { event, data, code, page: PageDispatcher.from(this, page) }); }); diff --git a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts index 1e406f15a0a83..671f2996ed910 100644 --- a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts +++ b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts @@ -32,6 +32,7 @@ import type { Progress } from '@protocol/progress'; export class RequestDispatcher extends Dispatcher implements channels.RequestChannel { _type_Request: boolean; private _browserContextDispatcher: BrowserContextDispatcher; + reportedThroughEvent = false; static from(scope: BrowserContextDispatcher, request: Request): RequestDispatcher { const result = scope.connection.existingDispatcher(request); @@ -48,9 +49,9 @@ export class RequestDispatcher extends Dispatcher(page) : null; - const frameDispatcher = frame ? FrameDispatcher.from(scope, frame) : null; + const frameDispatcher = FrameDispatcher.fromNullable(scope, frame); super(pageDispatcher || frameDispatcher || scope, request, 'Request', { - frame: FrameDispatcher.fromNullable(scope, request.frame()), + frame: frameDispatcher, serviceWorker: WorkerDispatcher.fromNullable(scope, request.serviceWorker()), url: request.url(), resourceType: request.resourceType(), diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index f975bad360634..b1585017b5494 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -339,6 +339,10 @@ export class PageDispatcher extends Dispatcher { + return { requests: this._page.networkRequests().map(request => RequestDispatcher.from(this.parentScope(), request)) }; + } + async snapshotForAI(params: channels.PageSnapshotForAIParams, progress: Progress): Promise { return { snapshot: await this._page.snapshotForAI(progress) }; } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 4fd2e7368bd9a..c705cef56327e 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -302,6 +302,7 @@ export class FrameManager { route?.abort('aborted').catch(() => {}); return; } + this._page.addNetworkRequest(request); this._page.emitOnContext(BrowserContext.Events.Request, request); if (route) new network.Route(request, route).handle([...this._page.requestInterceptors, ...this._page.browserContext.requestInterceptors]); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index b36b062fe3f42..d17c0ce76241f 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -166,6 +166,7 @@ export class Page extends SdkObject { private _locatorHandlers = new Map }>(); private _lastLocatorHandlerUid = 0; private _locatorHandlerRunningCounter = 0; + private _networkRequests: network.Request[] = []; // Aiming at 25 fps by default - each frame is 40ms, but we give some slack with 35ms. // When throttling for tracing, 200ms between frames, except for 10 frames around the action. @@ -350,6 +351,16 @@ export class Page extends SdkObject { return this._extraHTTPHeaders; } + addNetworkRequest(request: network.Request) { + this._networkRequests.push(request); + for (const collected of ensureArrayLimit(this._networkRequests, 100)) + this.emitOnContext(BrowserContext.Events.RequestCollected, collected); + } + + networkRequests() { + return this._networkRequests; + } + async onBindingCalled(payload: string, context: dom.FrameExecutionContext) { if (this._closedState === 'closed') return; @@ -1078,7 +1089,8 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frame return result; } -function ensureArrayLimit(array: any[], limit: number) { +function ensureArrayLimit(array: T[], limit: number): T[] { if (array.length > limit) - array.splice(0, limit / 10); + return array.splice(0, limit / 10); + return []; } diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 76f231b300c10..5e4ede100e7e3 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -131,6 +131,7 @@ export const methodMetainfo = new Map; + /** + * Returns up to 100 last network request from this page. See + * [page.on('request')](https://playwright.dev/docs/api/class-page#page-event-request) for more details. + */ + requests(): Promise>; + /** * Routing provides the capability to modify network requests that are made by a page. * diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index fe346de7f1588..e772163e131c6 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2121,6 +2121,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { accessibilitySnapshot(params: PageAccessibilitySnapshotParams, progress?: Progress): Promise; pageErrors(params?: PagePageErrorsParams, progress?: Progress): Promise; pdf(params: PagePdfParams, progress?: Progress): Promise; + requests(params?: PageRequestsParams, progress?: Progress): Promise; snapshotForAI(params: PageSnapshotForAIParams, progress?: Progress): Promise; startJSCoverage(params: PageStartJSCoverageParams, progress?: Progress): Promise; stopJSCoverage(params?: PageStopJSCoverageParams, progress?: Progress): Promise; @@ -2569,6 +2570,11 @@ export type PagePdfOptions = { export type PagePdfResult = { pdf: Binary, }; +export type PageRequestsParams = {}; +export type PageRequestsOptions = {}; +export type PageRequestsResult = { + requests: RequestChannel[], +}; export type PageSnapshotForAIParams = { timeout: number, }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index ef04a9d3a8233..b7658a2013d79 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1998,6 +1998,14 @@ Page: returns: pdf: binary + requests: + title: Get network requests + group: getter + returns: + requests: + type: array + items: Request + snapshotForAI: internal: true parameters: diff --git a/tests/page/page-event-request.spec.ts b/tests/page/page-event-request.spec.ts index 028ddcca54fbe..4bf4df5410c8e 100644 --- a/tests/page/page-event-request.spec.ts +++ b/tests/page/page-event-request.spec.ts @@ -376,3 +376,45 @@ it('should not expose preflight OPTIONS request with network interception', { `POST ${server.CROSS_PROCESS_PREFIX}/cors`, ]); }); + +it('should return last requests', async ({ page, server }) => { + await page.goto(server.PREFIX + '/title.html'); + for (let i = 0; i < 200; ++i) + server.setRoute('/fetch?' + i, (req, res) => res.end('url:' + server.PREFIX + req.url)); + + // #0 is the navigation request, so start with #1. + for (let i = 1; i < 50; ++i) + await page.evaluate(url => fetch(url), server.PREFIX + '/fetch?' + i); + const first50Requests = await page.requests(); + const firstReponse = await first50Requests[1].response(); + expect(await firstReponse.text()).toBe('url:' + server.PREFIX + '/fetch?1'); + + page.on('request', () => {}); + for (let i = 50; i < 100; ++i) + await page.evaluate(url => fetch(url), server.PREFIX + '/fetch?' + i); + const first100Requests = await page.requests(); + + for (let i = 100; i < 200; ++i) + await page.evaluate(url => fetch(url), server.PREFIX + '/fetch?' + i); + const last100Requests = await page.requests(); + + // Last 100 requests are fully functional. + const received = await Promise.all(last100Requests.map(async request => { + const response = await request.response(); + return { text: await response.text(), url: request.url() }; + })); + const expected = []; + for (let i = 100; i < 200; ++i) { + const url = server.PREFIX + '/fetch?' + i; + expected.push({ url, text: 'url:' + url }); + } + expect(received).toEqual(expected); + + // First 50 requests were collected. + const error = await first50Requests[1].response().catch(e => e); + expect(error.message).toContain('request.response: The object has been collected to prevent unbounded heap growth.'); + + // Second 50 requests are functional, because they were reported through the event and not collected. + const reponse50 = await first100Requests[50].response(); + expect(await reponse50.text()).toBe('url:' + server.PREFIX + '/fetch?50'); +}); diff --git a/tests/stress/heap.spec.ts b/tests/stress/heap.spec.ts index 66bedfe9ff265..aed05cff7b786 100644 --- a/tests/stress/heap.spec.ts +++ b/tests/stress/heap.spec.ts @@ -83,6 +83,18 @@ test('should not leak dispatchers after closing page', async ({ context, server expect(await queryObjectCount(require('../../packages/playwright-core/lib/client/network').Response)).toBe(0); }); +test('should not leak requests over 100', async ({ context, server }) => { + const page = await context.newPage(); + await page.goto(server.PREFIX + '/title.html'); + for (let i = 0; i < 100; ++i) + await page.evaluate(url => fetch(url), server.EMPTY_PAGE); + await page.requests(); + for (let i = 0; i < 200; ++i) + await page.evaluate(url => fetch(url), server.EMPTY_PAGE); + await page.requests(); + expect(await queryObjectCount(require('../../packages/playwright-core/lib/server/dispatchers/networkDispatchers').RequestDispatcher)).toBeLessThanOrEqual(100); +}); + test.describe(() => { test.beforeEach(() => { require('../../packages/playwright-core/lib/server/dispatchers/dispatcher').setMaxDispatchersForTest(100);