diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 93929394bf224..55ea7c92a2c21 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -591,8 +591,8 @@ class InterceptableRequest { (request as any)[kInterceptableRequest] = undefined; } - static createResponseBodyCallback(request: network.Request): () => Promise { - return async () => { + static createResponseBodyCallback(request: network.Request): (isExplicit: boolean) => Promise { + return async (isExplicit: boolean) => { // Lookup the request lazily, so that the response does not retain the // InterceptableRequest and its session after detachIfNeeded(). const interceptable = InterceptableRequest.from(request); @@ -610,11 +610,18 @@ class InterceptableRequest { if (interceptable._originalRequestRoute?._fulfilled) return Buffer.from(''); - // Re-fetching the resource may produce side effects on the server, only - // do it for GETs of static subresources and prefetch requests. + // Re-fetching the resource may produce side effects on the server, so + // never do it for non-GETs, explicit call or not: a caller who + // deliberately reads a POST's body did not ask to resubmit it. if (request.method() !== 'GET') return Buffer.from(''); - if (!kRefetchSafeResourceTypes.has(request.resourceType())) { + // For an automatic, blanket capture (harTracer.ts records every + // response for HAR/trace output, isExplicit=false) only re-fetch GETs + // that are safe to replay sight unseen: static subresources and + // prefetches (see #42002). A caller that explicitly awaited + // response.body() for this one response already accepts a re-fetch + // for it, whatever the resource type. + if (!isExplicit && !kRefetchSafeResourceTypes.has(request.resourceType())) { const rawHeaders = await request.internalRawRequestHeaders(); const isPrefetch = rawHeaders.some(h => h.name.toLowerCase() === 'sec-purpose' && h.value.startsWith('prefetch')); if (!isPrefetch) diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index d25f342645090..352c12c3d2b8e 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -477,7 +477,7 @@ export class Route extends SdkObject { export type RouteHandler = (route: Route, request: Request) => void; -type GetResponseBodyCallback = () => Promise; +type GetResponseBodyCallback = (isExplicit: boolean) => Promise; export type ResourceTiming = { startTime: number; @@ -546,7 +546,11 @@ export class Response extends SdkObject { } async body(progress: Progress): Promise { - return await this._request.raceWithPageClosure(progress, this.internalBody()); + // This is a deliberate, one-off call from client code (as opposed to + // harTracer's automatic per-response capture), so it is safe to recover + // an evicted body by re-fetching regardless of resource type: see the + // isExplicit parameter on internalBody(). + return await this._request.raceWithPageClosure(progress, this.internalBody(true)); } async securityDetails(progress: Progress): Promise { @@ -643,7 +647,16 @@ export class Response extends SdkObject { return await this._rawResponseHeadersPromise; } - async internalBody(): Promise { + // isExplicit distinguishes a deliberate, one-off body() call (from client + // code, via body() above) from an automatic, blanket capture of every + // response (harTracer.ts, for HAR/trace recording). When Chromium has + // evicted the body and it needs to be recovered via a real re-fetch, only + // the explicit case is allowed to do so for any GET: re-fetching every + // response automatically can silently duplicate requests with server-side + // side effects (see https://github.com/microsoft/playwright/issues/42002), + // but a test author who deliberately calls response.body() once already + // accepts that cost for the one response they asked for. + async internalBody(isExplicit = false): Promise { await this._finishedPromise; if (this._status >= 300 && this._status <= 399) throw new Error('Response body is unavailable for redirect responses'); @@ -652,7 +665,7 @@ export class Response extends SdkObject { return Buffer.from(body, isBase64 ? 'base64' : 'utf-8'); } try { - return await this._getResponseBodyCallback(); + return await this._getResponseBodyCallback(isExplicit); } catch (e) { if (isProtocolError(e) && e.type === 'error') rewriteErrorMessage(e, e.message + '\nResponse body is not available for a response that was navigated away from. Read response.body() before triggering any navigation.'); diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index d1bbf7593c754..a6d86cc411a3f 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -392,6 +392,32 @@ it('should return body for image with evicted body', { expect(body.toString('base64')).toBe(imageBase64); }); +it('should return body for a fetch()ed resource with evicted body when the page already read it', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42862' }, +}, async ({ page, server, isMac, browserName }) => { + it.fixme(isMac && browserName === 'webkit', 'WebKit on Mac evicts the body and returns empty buffer'); + // Any app that turns a fetch() response into a downloadable file reads the + // body in-page first (here via .blob(), same as URL.createObjectURL-based + // downloads). That consumption is what makes Chromium evict the body, so + // response.body() needs the explicit re-fetch path even though "fetch" is + // not in the static-subresource allowlist used for automatic HAR capture. + server.setRoute('/resource.bin', (req, res) => { + res.setHeader('content-type', 'application/octet-stream'); + res.end(Buffer.from('hello from resource.bin')); + }); + server.setRoute('/page.html', (req, res) => { + res.setHeader('content-type', 'text/html'); + res.end(''); + }); + await page.goto(server.PREFIX + '/page.html'); + const [response] = await Promise.all([ + page.waitForResponse('**/resource.bin'), + page.evaluate(() => window['fetchAndConsume']()), + ]); + const body = await response.body(); + expect(body.toString()).toBe('hello from resource.bin'); +}); + it('should bypass disk cache when page interception is enabled', async ({ page, server }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30000' }); await page.goto(server.PREFIX + '/frames/one-frame.html');