diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index a5628e83b78aa..7ab0b00b376dc 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -379,17 +379,28 @@ export class CRNetworkManager { const getResponseBody = async () => { const contentLengthHeader = Object.entries(responsePayload.headers).find(header => header[0].toLowerCase() === 'content-length'); const expectedLength = contentLengthHeader ? +contentLengthHeader[1] : undefined; + const hasContentEncoding = Object.keys(responsePayload.headers).some(h => h.toLowerCase() === 'content-encoding'); const session = request.session; const response = await session.send('Network.getResponseBody', { requestId: request._requestId }); - if (response.body || !expectedLength) - return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); + if (response.body || !expectedLength) { + const buffer = Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); + // When CDP returns non-base64 text for a response with Content-Length, + // it may have decoded binary data as UTF-8, corrupting non-UTF-8 bytes. + // Detect this by comparing Content-Length with actual byte length, + // and fall through to re-fetch via loadNetworkResource. + // Skip the check when Content-Encoding is present (gzip, br, etc.) + // because Content-Length refers to the encoded size, not the decoded body. + if (response.base64Encoded || !expectedLength || hasContentEncoding || buffer.byteLength === expectedLength) + return buffer; + } // Make sure no network requests sent while reading the body for fulfilled requests. if (request._originalRequestRoute?._fulfilled) return Buffer.from(''); - // For which returns an empty body. const resource = await session.send('Network.loadNetworkResource', { url: request.request.url(), frameId: this._serviceWorker ? undefined : request.request.frame()!._id, options: { disableCache: false, includeCredentials: true } }); const chunks: Buffer[] = []; while (resource.resource.stream) { diff --git a/tests/page/page-goto.spec.ts b/tests/page/page-goto.spec.ts index d6bbad9856e37..a7516f1471cc6 100644 --- a/tests/page/page-goto.spec.ts +++ b/tests/page/page-goto.spec.ts @@ -745,8 +745,7 @@ it('should work with lazy loading iframes', async ({ page, server, isAndroid }) }); it('should report raw buffer for main resource', async ({ page, server, browserName, platform, channel }) => { - it.fail(browserName === 'chromium', 'Chromium sends main resource as text'); - it.fail(browserName === 'webkit' && platform === 'win32' && channel !== 'webkit-wsl', 'Same here'); + it.fail(browserName === 'webkit' && platform === 'win32' && channel !== 'webkit-wsl', 'WebKit on Windows sends main resource as text'); server.setRoute('/empty.html', (req, res) => { res.statusCode = 200; diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index d6ad9f9715f5a..287ae72db5469 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -149,6 +149,26 @@ it('should return body with compression', async ({ page, server, asset }) => { expect(responseBuffer.equals(imageBuffer)).toBe(true); }); +it('should return binary body with text content-type', async ({ page, server, browserName }) => { + it.skip(browserName !== 'chromium', 'Binary body preservation is currently Chromium-specific'); + // Binary data with bytes that are invalid UTF-8. + const binaryData = Buffer.from([0x80, 0x81, 0x82, 0xFF, 0xFE, 0x00, 0x01, 0x02]); + server.setRoute('/binary-as-text', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/plain;charset=UTF-8', + 'Content-Length': binaryData.length, + }); + res.end(binaryData); + }); + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse(server.PREFIX + '/binary-as-text'), + page.evaluate(url => fetch(url), server.PREFIX + '/binary-as-text'), + ]); + const body = await response.body(); + expect(body.equals(binaryData)).toBe(true); +}); + it('should return status text', async ({ page, server }) => { server.setRoute('/cool', (req, res) => { res.writeHead(200, 'cool!');