Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions packages/playwright-core/src/server/chromium/crNetworkManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,8 +591,8 @@ class InterceptableRequest {
(request as any)[kInterceptableRequest] = undefined;
}

static createResponseBodyCallback(request: network.Request): () => Promise<Buffer> {
return async () => {
static createResponseBodyCallback(request: network.Request): (isExplicit: boolean) => Promise<Buffer> {
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);
Expand All @@ -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)
Expand Down
21 changes: 17 additions & 4 deletions packages/playwright-core/src/server/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ export class Route extends SdkObject {

export type RouteHandler = (route: Route, request: Request) => void;

type GetResponseBodyCallback = () => Promise<Buffer>;
type GetResponseBodyCallback = (isExplicit: boolean) => Promise<Buffer>;

export type ResourceTiming = {
startTime: number;
Expand Down Expand Up @@ -546,7 +546,11 @@ export class Response extends SdkObject {
}

async body(progress: Progress): Promise<Buffer> {
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<SecurityDetails | null> {
Expand Down Expand Up @@ -643,7 +647,16 @@ export class Response extends SdkObject {
return await this._rawResponseHeadersPromise;
}

async internalBody(): Promise<Buffer> {
// 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<Buffer> {
await this._finishedPromise;
if (this._status >= 300 && this._status <= 399)
throw new Error('Response body is unavailable for redirect responses');
Expand All @@ -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.');
Expand Down
26 changes: 26 additions & 0 deletions tests/page/page-network-response.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html><body><script>window.fetchAndConsume = () => fetch("/resource.bin").then(r => r.blob());</script></body></html>');
});
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');
Expand Down