From 119aac66cb19d885d10f2481316893018c4734e4 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 21 Jul 2026 11:38:43 -0700 Subject: [PATCH] feat(network): add request.body async getters for blob request bodies Chromium and WebKit only report hasPostData for blob-backed request bodies on Network.requestWillBeSent. Fetch such bodies asynchronously via Network.getRequestPostData and deliver them through the request.body()/bodyBuffer()/bodyJSON() getters restored from #38179. WebKit support requires build 2337. Fixes: https://github.com/microsoft/playwright/issues/6479 --- docs/src/api/class-request.md | 44 ++- packages/isomorphic/protocolMetainfo.ts | 1 + packages/playwright-client/types/types.d.ts | 35 ++- .../playwright-core/src/client/channels.d.ts | 6 + .../playwright-core/src/client/network.ts | 17 +- .../playwright-core/src/server/channels.d.ts | 6 + .../src/server/chromium/crNetworkManager.ts | 14 + .../server/dispatchers/networkDispatchers.ts | 5 + .../playwright-core/src/server/network.ts | 17 ++ .../src/server/webkit/protocol.d.ts | 21 ++ .../src/server/webkit/wkPage.ts | 13 + packages/playwright-core/types/types.d.ts | 35 ++- packages/protocol/spec/network.yml | 6 + packages/protocol/src/validator.ts | 4 + tests/page/network-request-body.spec.ts | 259 ++++++++++++++++++ utils/generate_channels.js | 2 +- 16 files changed, 463 insertions(+), 22 deletions(-) create mode 100644 tests/page/network-request-body.spec.ts diff --git a/docs/src/api/class-request.md b/docs/src/api/class-request.md index 6ae84828f1f02..78117d142a6b2 100644 --- a/docs/src/api/class-request.md +++ b/docs/src/api/class-request.md @@ -23,6 +23,32 @@ request is issued to a redirected url. An object with all the request HTTP headers associated with this request. The header names are lower-cased. +## async method: Request.body +* since: v1.62 +- returns: <[null]|[string]> + +The request body, if present. Unlike [`method: Request.postData`], this method also works for request bodies that the browser only reports after the request has been sent, for example `Blob` or `File` payloads in Chromium-based browsers. + +## async method: Request.bodyBuffer +* since: v1.62 +- returns: <[null]|[Buffer]> + +The request body in a binary form. Returns null if the request has no body. + +## async method: Request.bodyJSON +* since: v1.62 +* langs: js, python +- returns: <[null]|[Serializable]> + +Returns the request body as a parsed JSON object. If the request `Content-Type` is `application/x-www-form-urlencoded`, this method returns a key/value object parsed from the form data. Otherwise, it parses the body as JSON. + +## async method: Request.bodyJSON +* since: v1.62 +* langs: csharp +- returns: <[null]|[JsonElement]> + +Returns the request body as a parsed JSON object. If the request `Content-Type` is `application/x-www-form-urlencoded`, this method returns a key/value object parsed from the form data. Otherwise, it parses the body as JSON. + ## method: Request.failure * since: v1.8 - returns: <[null]|[string]> @@ -151,35 +177,33 @@ Request's method (GET, POST, etc.) ## method: Request.postData * since: v1.8 +* discouraged: Use [`method: Request.body`] instead. - returns: <[null]|[string]> -Request's post body, if any. +The request body, if present. ## method: Request.postDataBuffer * since: v1.8 +* discouraged: Use [`method: Request.bodyBuffer`] instead. - returns: <[null]|[Buffer]> -Request's post body in a binary form, if any. +The request body in a binary form. Returns null if the request has no body. ## method: Request.postDataJSON * since: v1.8 * langs: js, python +* discouraged: Use [`method: Request.bodyJSON`] instead. - returns: <[null]|[Serializable]> -Returns parsed request's body for `form-urlencoded` and JSON as a fallback if any. - -When the response is `application/x-www-form-urlencoded` then a key/value object of the values will be returned. -Otherwise it will be parsed as JSON. +Returns the request body as a parsed JSON object. If the request `Content-Type` is `application/x-www-form-urlencoded`, this method returns a key/value object parsed from the form data. Otherwise, it parses the body as JSON. ## method: Request.postDataJSON * since: v1.12 * langs: csharp +* discouraged: Use [`method: Request.bodyJSON`] instead. - returns: <[null]|[JsonElement]> -Returns parsed request's body for `form-urlencoded` and JSON as a fallback if any. - -When the response is `application/x-www-form-urlencoded` then a key/value object of the values will be returned. -Otherwise it will be parsed as JSON. +Returns the request body as a parsed JSON object. If the request `Content-Type` is `application/x-www-form-urlencoded`, this method returns a key/value object parsed from the form data. Otherwise, it parses the body as JSON. ## method: Request.redirectedFrom * since: v1.8 diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index cf93e3f62f25d..b496c621644d6 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -230,6 +230,7 @@ export const methodMetainfo = new Map([ ['LocalUtils.addStackToTracingNoReply', { internal: true, }], ['LocalUtils.traceDiscarded', { internal: true, }], ['LocalUtils.globToRegex', { internal: true, }], + ['Request.body', { title: 'Get request body', group: 'getter', }], ['Request.response', { internal: true, }], ['Request.rawRequestHeaders', { internal: true, }], ['Route.redirectNavigationRequest', { internal: true, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 0b3086a126811..39c15e014794c 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -21850,6 +21850,26 @@ export interface Request { */ allHeaders(): Promise<{ [key: string]: string; }>; + /** + * The request body, if present. Unlike + * [request.postData()](https://playwright.dev/docs/api/class-request#request-post-data), this method also works for + * request bodies that the browser only reports after the request has been sent, for example `Blob` or `File` payloads + * in Chromium-based browsers. + */ + body(): Promise; + + /** + * The request body in a binary form. Returns null if the request has no body. + */ + bodyBuffer(): Promise; + + /** + * Returns the request body as a parsed JSON object. If the request `Content-Type` is + * `application/x-www-form-urlencoded`, this method returns a key/value object parsed from the form data. Otherwise, + * it parses the body as JSON. + */ + bodyJSON(): Promise; + /** * Returns the [Response](https://playwright.dev/docs/api/class-response) object if the response has already been * received, `null` otherwise. @@ -21957,20 +21977,25 @@ export interface Request { method(): string; /** - * Request's post body, if any. + * **NOTE** Use [request.body()](https://playwright.dev/docs/api/class-request#request-body) instead. + * + * The request body, if present. */ postData(): null|string; /** - * Request's post body in a binary form, if any. + * **NOTE** Use [request.bodyBuffer()](https://playwright.dev/docs/api/class-request#request-body-buffer) instead. + * + * The request body in a binary form. Returns null if the request has no body. */ postDataBuffer(): null|Buffer; /** - * Returns parsed request's body for `form-urlencoded` and JSON as a fallback if any. + * **NOTE** Use [request.bodyJSON()](https://playwright.dev/docs/api/class-request#request-body-json) instead. * - * When the response is `application/x-www-form-urlencoded` then a key/value object of the values will be returned. - * Otherwise it will be parsed as JSON. + * Returns the request body as a parsed JSON object. If the request `Content-Type` is + * `application/x-www-form-urlencoded`, this method returns a key/value object parsed from the form data. Otherwise, + * it parses the body as JSON. */ postDataJSON(): null|Serializable; diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index ee239a9c51efc..0653e6fbff523 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -3599,9 +3599,15 @@ export interface RequestEventTarget { } export interface RequestChannel extends RequestEventTarget, Channel { _type_Request: boolean; + body(params: RequestBodyParams, options: TimeoutOptions): Promise; response(params: RequestResponseParams, options: TimeoutOptions): Promise; rawRequestHeaders(params: RequestRawRequestHeadersParams, options: TimeoutOptions): Promise; } +export type RequestBodyParams = {}; +export type RequestBodyOptions = {}; +export type RequestBodyResult = { + body?: Binary, +}; export type RequestResponseParams = {}; export type RequestResponseOptions = {}; export type RequestResponseResult = { diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 4d36f5dd0a8b1..e2c2d989001ae 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -136,6 +136,18 @@ export class Request extends ChannelOwner implements ap return this._fallbackOverrides.method || this._initializer.method; } + async body(): Promise { + return (await this.bodyBuffer())?.toString('utf-8') || null; + } + + async bodyBuffer(): Promise { + return this._fallbackOverrides.postDataBuffer || (await this._channel.body({}, kNoTimeout)).body || null; + } + + async bodyJSON(): Promise { + return this._postDataJSON(await this.body()); + } + postData(): string | null { return (this._fallbackOverrides.postDataBuffer || this._initializer.postData)?.toString('utf-8') || null; } @@ -145,7 +157,10 @@ export class Request extends ChannelOwner implements ap } postDataJSON(): Object | null { - const postData = this.postData(); + return this._postDataJSON(this.postData()); + } + + private _postDataJSON(postData: string | null): Object | null { if (!postData) return null; diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index b66eb1753d7ef..f07d232ab0d68 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -3600,9 +3600,15 @@ export interface RequestEventTarget { } export interface RequestChannel extends RequestEventTarget, Channel { _type_Request: boolean; + body(params: RequestBodyParams, progress: Progress): Promise; response(params: RequestResponseParams, progress: Progress): Promise; rawRequestHeaders(params: RequestRawRequestHeadersParams, progress: Progress): Promise; } +export type RequestBodyParams = {}; +export type RequestBodyOptions = {}; +export type RequestBodyResult = { + body?: Binary, +}; export type RequestResponseParams = {}; export type RequestResponseOptions = {}; export type RequestResponseResult = { diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 1c720b5821004..2bfa10f513800 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -215,6 +215,19 @@ export class CRNetworkManager { } } + // When the request body is not available synchronously (e.g. a Blob or File body), Network.requestWillBeSent + // only reports hasPostData, and the bytes must be retrieved with Network.getRequestPostData. Fetch the body + // asynchronously and push it to the request once available. See https://github.com/microsoft/playwright/issues/6479. + private _maybeFetchPostData(sessionInfo: SessionInfo, requestWillBeSentEvent: Protocol.Network.requestWillBeSentPayload, requestPausedEvent: Protocol.Fetch.requestPausedPayload | undefined, request: network.Request) { + const cdpRequest = requestPausedEvent ? requestPausedEvent.request : requestWillBeSentEvent.request; + if (!cdpRequest.hasPostData || request.postDataBuffer()) + return; + request.deferPostData(); + sessionInfo.session.send('Network.getRequestPostData', { requestId: requestWillBeSentEvent.requestId }) + .then(result => request.resolvePostData(Buffer.from(result.postData, result.base64Encoded ? 'base64' : 'utf-8'))) + .catch(() => request.resolvePostData(null)); + } + _onRequestServedFromCache(event: Protocol.Network.requestServedFromCachePayload) { this._responseExtraInfoTracker.requestServedFromCache(event); } @@ -372,6 +385,7 @@ export class CRNetworkManager { headersOverride: headersOverride || null, }); this._requestIdToRequest.set(requestWillBeSentEvent.requestId, request); + this._maybeFetchPostData(requestWillBeSentSessionInfo, requestWillBeSentEvent, requestPausedEvent, request.request); if (route) { // We may not receive extra info when intercepting the request. diff --git a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts index 8a7cfd3e8412e..f1d5a24b5aff6 100644 --- a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts +++ b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts @@ -66,6 +66,11 @@ export class RequestDispatcher extends Dispatcher { + const body = await this._object.body(progress); + return { body: body === null ? undefined : body }; + } + async rawRequestHeaders(params: channels.RequestRawRequestHeadersParams, progress: Progress): Promise { return { headers: await this._object.rawRequestHeaders(progress) }; } diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 8de38cf756a67..c9564ec2c3870 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -187,6 +187,7 @@ export class Request extends SdkObject { readonly _serviceWorker: pages.Worker | null = null; readonly _context: contexts.BrowserContext; private _rawRequestHeadersPromise = new ManualPromise(); + private _deferredPostDataPromise: ManualPromise | undefined; private _waitForResponsePromise = new ManualPromise(); _responseEndTiming = -1; private _overrides: NormalizedContinueOverrides | undefined; @@ -267,6 +268,22 @@ export class Request extends SdkObject { return this._overrides?.postData || this._postData; } + // Marks that the post data is not available at request start and will be delivered + // asynchronously via resolvePostData(). + deferPostData() { + this._deferredPostDataPromise = new ManualPromise(); + } + + resolvePostData(postData: Buffer | null) { + if (postData && !this._postData) + this._postData = postData; + this._deferredPostDataPromise?.resolve(this.postDataBuffer()); + } + + async body(progress: Progress): Promise { + return await this.raceWithPageClosure(progress, this._deferredPostDataPromise ?? Promise.resolve(this.postDataBuffer())); + } + headers(): HeadersArray { return this._overrides?.headers || this._headers; } diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index cde57e4dc9b64..9b74a86ad59f7 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -5535,6 +5535,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the * HTTP POST request data. */ postData?: string; + /** + * True when the request has a body which is not included in postData, e.g. because it references blobs. The body can be retrieved with getRequestPostData. + */ + hasPostData?: boolean; /** * The level of included referrer information. */ @@ -6070,6 +6074,21 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the } export type setExtraHTTPHeadersReturnValue = { } + /** + * Returns post data sent with the request. Returns an error when no data was sent with the request. + */ + export type getRequestPostDataParameters = { + /** + * Identifier of the network request to get content for. + */ + requestId: RequestId; + } + export type getRequestPostDataReturnValue = { + /** + * Base64-encoded request body. + */ + postData: string; + } /** * Returns content served for the given request. */ @@ -9588,6 +9607,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the "Network.enable": Network.enableParameters; "Network.disable": Network.disableParameters; "Network.setExtraHTTPHeaders": Network.setExtraHTTPHeadersParameters; + "Network.getRequestPostData": Network.getRequestPostDataParameters; "Network.getResponseBody": Network.getResponseBodyParameters; "Network.setResourceCachingDisabled": Network.setResourceCachingDisabledParameters; "Network.setClearResourceDataOnNavigate": Network.setClearResourceDataOnNavigateParameters; @@ -9898,6 +9918,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the "Network.enable": Network.enableReturnValue; "Network.disable": Network.disableReturnValue; "Network.setExtraHTTPHeaders": Network.setExtraHTTPHeadersReturnValue; + "Network.getRequestPostData": Network.getRequestPostDataReturnValue; "Network.getResponseBody": Network.getResponseBodyReturnValue; "Network.setResourceCachingDisabled": Network.setResourceCachingDisabledReturnValue; "Network.setClearResourceDataOnNavigate": Network.setClearResourceDataOnNavigateReturnValue; diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 5258751640416..df3d2c1b75a4a 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -1070,6 +1070,18 @@ export class WKPage implements PageDelegate { this._onRequest(session, event, false); } + // When the request body references blobs, Network.requestWillBeSent only reports hasPostData, + // and the bytes must be retrieved asynchronously with Network.getRequestPostData. Fetch the body + // asynchronously and push it to the request once available. See https://github.com/microsoft/playwright/issues/6479. + private _maybeFetchPostData(session: WKSession, event: Protocol.Network.requestWillBeSentPayload, request: network.Request) { + if (!event.request.hasPostData || request.postDataBuffer()) + return; + request.deferPostData(); + session.send('Network.getRequestPostData', { requestId: event.requestId }) + .then(result => request.resolvePostData(Buffer.from(result.postData, 'base64'))) + .catch(() => request.resolvePostData(null)); + } + private _onRequest(session: WKSession, event: Protocol.Network.requestWillBeSentPayload, intercepted: boolean) { let redirectedFrom: WKInterceptableRequest | null = null; if (event.redirectResponse) { @@ -1098,6 +1110,7 @@ export class WKPage implements PageDelegate { request.request.setRawRequestHeaders(null); } this._requestIdToRequest.set(event.requestId, request); + this._maybeFetchPostData(session, event, request.request); this._page.frameManager.requestStarted(request.request, route); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 0b3086a126811..39c15e014794c 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -21850,6 +21850,26 @@ export interface Request { */ allHeaders(): Promise<{ [key: string]: string; }>; + /** + * The request body, if present. Unlike + * [request.postData()](https://playwright.dev/docs/api/class-request#request-post-data), this method also works for + * request bodies that the browser only reports after the request has been sent, for example `Blob` or `File` payloads + * in Chromium-based browsers. + */ + body(): Promise; + + /** + * The request body in a binary form. Returns null if the request has no body. + */ + bodyBuffer(): Promise; + + /** + * Returns the request body as a parsed JSON object. If the request `Content-Type` is + * `application/x-www-form-urlencoded`, this method returns a key/value object parsed from the form data. Otherwise, + * it parses the body as JSON. + */ + bodyJSON(): Promise; + /** * Returns the [Response](https://playwright.dev/docs/api/class-response) object if the response has already been * received, `null` otherwise. @@ -21957,20 +21977,25 @@ export interface Request { method(): string; /** - * Request's post body, if any. + * **NOTE** Use [request.body()](https://playwright.dev/docs/api/class-request#request-body) instead. + * + * The request body, if present. */ postData(): null|string; /** - * Request's post body in a binary form, if any. + * **NOTE** Use [request.bodyBuffer()](https://playwright.dev/docs/api/class-request#request-body-buffer) instead. + * + * The request body in a binary form. Returns null if the request has no body. */ postDataBuffer(): null|Buffer; /** - * Returns parsed request's body for `form-urlencoded` and JSON as a fallback if any. + * **NOTE** Use [request.bodyJSON()](https://playwright.dev/docs/api/class-request#request-body-json) instead. * - * When the response is `application/x-www-form-urlencoded` then a key/value object of the values will be returned. - * Otherwise it will be parsed as JSON. + * Returns the request body as a parsed JSON object. If the request `Content-Type` is + * `application/x-www-form-urlencoded`, this method returns a key/value object parsed from the form data. Otherwise, + * it parses the body as JSON. */ postDataJSON(): null|Serializable; diff --git a/packages/protocol/spec/network.yml b/packages/protocol/spec/network.yml index 7c2eca411d799..2607750f5144a 100644 --- a/packages/protocol/spec/network.yml +++ b/packages/protocol/spec/network.yml @@ -69,6 +69,12 @@ Request: commands: + body: + title: Get request body + group: getter + returns: + body: binary? + response: internal: true returns: diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 5c080081f7e09..78cf2d10a701a 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -2062,6 +2062,10 @@ scheme.RequestInitializer = tObject({ isNavigationRequest: tBoolean, redirectedFrom: tOptional(tChannel(['Request'])), }); +scheme.RequestBodyParams = tOptional(tObject({})); +scheme.RequestBodyResult = tObject({ + body: tOptional(tBinary), +}); scheme.RequestResponseParams = tOptional(tObject({})); scheme.RequestResponseResult = tObject({ response: tOptional(tChannel(['Response'])), diff --git a/tests/page/network-request-body.spec.ts b/tests/page/network-request-body.spec.ts new file mode 100644 index 0000000000000..b8a10dc4c8982 --- /dev/null +++ b/tests/page/network-request-body.spec.ts @@ -0,0 +1,259 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * 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 { test as it, expect } from './pageTest'; + +it('should return correct request body buffer for utf-8 body', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + const value = 'baẞ'; + const [request] = await Promise.all([ + page.waitForRequest('**'), + page.evaluate(({ url, value }) => { + const request = new Request(url, { + method: 'POST', + body: JSON.stringify(value), + }); + request.headers.set('content-type', 'application/json;charset=UTF-8'); + return fetch(request); + }, { url: server.PREFIX + '/title.html', value }) + ]); + expect((await request.bodyBuffer()).equals(Buffer.from(JSON.stringify(value), 'utf-8'))).toBe(true); + expect(await request.bodyJSON()).toBe(value); +}); + +it('should return request body w/o content-type @smoke', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest('**'), + page.evaluate(({ url }) => { + const request = new Request(url, { + method: 'POST', + body: JSON.stringify({ value: 42 }), + }); + request.headers.set('content-type', ''); + return fetch(request); + }, { url: server.PREFIX + '/title.html' }) + ]); + expect(await request.bodyJSON()).toEqual({ value: 42 }); +}); + +it('should throw on invalid JSON in post data', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest('**'), + page.evaluate(({ url }) => { + const request = new Request(url, { + method: 'POST', + body: '', + }); + return fetch(request); + }, { url: server.PREFIX + '/title.html' }) + ]); + let error; + try { + await request.bodyJSON(); + } catch (e) { + error = e; + } + expect(error.message).toContain('POST data is not a valid JSON object: '); +}); + +it('should return body for PUT requests', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest('**'), + page.evaluate(({ url }) => { + const request = new Request(url, { + method: 'PUT', + body: JSON.stringify({ value: 42 }), + }); + return fetch(request); + }, { url: server.PREFIX + '/title.html' }) + ]); + expect(await request.bodyJSON()).toEqual({ value: 42 }); +}); + +it('should get request body for file/blob', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/6479' }); + it.fail(browserName === 'webkit', 'Blob request body is not reported in WebKit'); + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest('**/*'), + page.evaluate(() => { + const file = new File(['file-contents'], 'filename.txt'); + + void fetch('/data', { + method: 'POST', + headers: { + 'content-type': 'application/octet-stream' + }, + body: file + }); + }) + ]); + expect(await request.body()).toBe('file-contents'); +}); + +it('should get request body for navigator.sendBeacon api calls', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/12231' }); + it.fail(browserName === 'webkit', 'body is empty'); + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest('**/*'), + page.evaluate(() => navigator.sendBeacon(window.location.origin + '/api/foo', new Blob([JSON.stringify({ foo: 'bar' })]))) + ]); + expect(request.method()).toBe('POST'); + expect(request.url()).toBe(server.PREFIX + '/api/foo'); + expect(await request.bodyJSON()).toStrictEqual({ foo: 'bar' }); +}); + +it('should return body for a binary blob request body', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/6479' }); + it.fail(browserName === 'webkit', 'Blob request body is not reported in WebKit'); + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (req, res) => res.end()); + const requestPromise = page.waitForRequest('**/post'); + await page.evaluate(async () => { + const blob = new Blob([new Uint8Array(Array.from(Array(256).keys()))]); + await fetch('./post', { method: 'POST', body: blob }); + }); + const request = await requestPromise; + const buffer = await request.bodyBuffer(); + expect(buffer.length).toBe(256); + for (let i = 0; i < 256; ++i) + expect(buffer[i]).toBe(i); +}); + +it('should return body for a blob request body with interception', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/6479' }); + it.fail(browserName === 'webkit', 'Blob request body is not reported in WebKit'); + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (req, res) => res.end()); + await page.route('**/post', route => route.continue()); + const requestPromise = page.waitForRequest('**/post'); + await page.evaluate(async () => { + const file = new File(['file-contents'], 'foo.txt', { type: 'application/octet-stream' }); + await fetch('./post', { method: 'POST', body: file }); + }); + const request = await requestPromise; + expect(await request.body()).toBe('file-contents'); +}); + +it('should return body for a blob request body after a redirect', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/6479' }); + it.fail(browserName === 'webkit', 'Blob request body is not reported in WebKit'); + await page.goto(server.EMPTY_PAGE); + server.setRoute('/redirect', (req, res) => { + res.writeHead(307, { location: '/post' }); + res.end(); + }); + server.setRoute('/post', (req, res) => res.end()); + const requestPromise = page.waitForRequest('**/post'); + await page.evaluate(async () => { + const file = new File(['file-contents'], 'foo.txt', { type: 'application/octet-stream' }); + await fetch('./redirect', { method: 'POST', body: file }); + }); + const request = await requestPromise; + expect(await request.body()).toBe('file-contents'); + expect(await request.redirectedFrom().body()).toBe('file-contents'); +}); + +it('should return body for a form submission with a file input', async ({ page, server, asset, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/6479' }); + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (req, res) => res.end()); + await page.setContent(`
+ + + +
`); + await page.setInputFiles('input[type=file]', asset('file-to-upload.txt')); + const requestPromise = page.waitForRequest('**/post'); + await page.click('input[type=submit]'); + const request = await requestPromise; + const body = await request.body(); + expect(body).toContain('Content-Disposition: form-data; name="textfield"\r\n\r\ntext-value'); + expect(body).toContain('Content-Disposition: form-data; name="filefield"; filename="file-to-upload.txt"'); + // Contents of files backed by disk are only reported in Firefox, Chromium and WebKit omit them. + if (browserName === 'firefox') + expect(body).toContain('contents of the file'); + else + expect(body).not.toContain('contents of the file'); +}); + +it('should return body for a fetch request with a file-backed FormData', async ({ page, server, asset, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/6479' }); + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (req, res) => res.end()); + await page.setContent(``); + await page.setInputFiles('input', asset('file-to-upload.txt')); + const requestPromise = page.waitForRequest('**/post'); + await page.evaluate(() => { + const formData = new FormData(); + formData.append('textfield', 'text-value'); + formData.append('filefield', document.querySelector('input').files[0]); + return fetch('/post', { method: 'POST', body: formData }); + }); + const request = await requestPromise; + const body = await request.body(); + expect(body).toContain('Content-Disposition: form-data; name="textfield"\r\n\r\ntext-value'); + expect(body).toContain('Content-Disposition: form-data; name="filefield"; filename="file-to-upload.txt"'); + // Contents of files backed by disk are only reported in Firefox, Chromium and WebKit omit them. + if (browserName === 'firefox') + expect(body).toContain('contents of the file'); + else + expect(body).not.toContain('contents of the file'); +}); + +it('should return body for a cloned request', async ({ page, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/6479' }); + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (req, res) => res.end()); + const requestPromise = page.waitForRequest('**/post'); + await page.evaluate(() => { + // Cloning the request turns its body into a blob. + const request = new Request('./post', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ test: 'test' }), + }); + return fetch(request.clone()); + }); + const request = await requestPromise; + expect(await request.body()).toBe('{"test":"test"}'); + expect(await request.bodyJSON()).toEqual({ test: 'test' }); +}); + +it('should not have body for a ReadableStream request body', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/6479' }); + it.skip(browserName !== 'chromium', 'Streaming request bodies are only supported in Chromium'); + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (req, res) => res.end()); + const requestPromise = page.waitForRequest('**/post'); + await page.evaluate(() => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('stream-contents')); + controller.close(); + } + }); + // The fetch itself fails as streaming upload requires HTTP/2 or TLS, but the request is still sent. + return fetch('./post', { method: 'POST', body: stream, duplex: 'half' } as RequestInit).catch(() => {}); + }); + const request = await requestPromise; + expect(await request.body()).toBe(null); + expect(await request.bodyBuffer()).toBe(null); +}); diff --git a/utils/generate_channels.js b/utils/generate_channels.js index b0b44bb84647c..aed36b491792f 100755 --- a/utils/generate_channels.js +++ b/utils/generate_channels.js @@ -324,7 +324,7 @@ function generateChannels(target) { if (!method.title && !method.internal) throw new Error(`Method "${className}.${methodName}" must have a "title" because it is not "internal" in protocol.yml`); if (method.group && method.internal) - throw new Error(`Method "${className}.${methodName}" must should not specify "group" because it is "internal" in protocol.yml`); + throw new Error(`Method "${className}.${methodName}" must not specify "group" because it is "internal" in protocol.yml`); if (method.group && !['getter', 'configuration', 'route', 'default'].includes(method.group)) throw new Error(`Unknown group "${method.group}" for method "${className}.${methodName}" in protocol.yml`); const internalProp = method.internal ? ` internal: ${method.internal},` : '';