diff --git a/packages/html-reporter/src/gantt.tsx b/packages/html-reporter/src/gantt.tsx
index de875e87101cb..0151773157beb 100644
--- a/packages/html-reporter/src/gantt.tsx
+++ b/packages/html-reporter/src/gantt.tsx
@@ -157,7 +157,6 @@ export const GanttChart = ({
height={barHeight}
fill={color}
rx='2'
- tabIndex={0}
>
{entry.tooltip}
diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts
index 7299449c041ad..d4f1376588d52 100644
--- a/packages/playwright-core/src/server/fetch.ts
+++ b/packages/playwright-core/src/server/fetch.ts
@@ -482,8 +482,6 @@ export abstract class APIRequestContext extends SdkObject {
return;
}
}
- response.on('aborted', () => reject(new Error('aborted')));
-
const chunks: Buffer[] = [];
const notifyBodyFinished = () => {
const body = Buffer.concat(chunks);
@@ -522,11 +520,21 @@ export abstract class APIRequestContext extends SdkObject {
// Brotli and deflate decompressors throw if the input stream is empty.
const emptyStreamTransform = new SafeEmptyStreamTransform(notifyBodyFinished);
body = pipeline(response, emptyStreamTransform, transform, e => {
- if (e)
- reject(new Error(`failed to decompress '${encoding}' encoding: ${e.message}`));
+ if (e) {
+ if (isNetworkConnectionError(e))
+ reject(e);
+ else
+ reject(new Error(`failed to decompress '${encoding}' encoding: ${e.message}`));
+ }
+ });
+ body.on('error', e => {
+ if (isNetworkConnectionError(e))
+ reject(e);
+ else
+ reject(new Error(`failed to decompress '${encoding}' encoding: ${e}`));
});
- body.on('error', e => reject(new Error(`failed to decompress '${encoding}' encoding: ${e}`)));
} else {
+ response.on('aborted', () => reject(new Error('aborted')));
body.on('error', reject);
}
@@ -804,6 +812,11 @@ function removeHeader(headers: { [name: string]: string }, name: string) {
delete headers[existing[0]];
}
+function isNetworkConnectionError(e: any): boolean {
+ const code = e?.code;
+ return code === 'ECONNRESET' || code === 'EPIPE' || code === 'ECONNABORTED';
+}
+
function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) {
const { username, password } = credentials;
const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64');
diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts
index 871e435b6df70..0b755200a662f 100644
--- a/tests/library/browsercontext-fetch.spec.ts
+++ b/tests/library/browsercontext-fetch.spec.ts
@@ -1401,3 +1401,62 @@ it('should retry on ECONNRESET', {
expect(await response.text()).toBe('Hello!');
expect(requestCount).toBe(4);
});
+
+it('should retry ECONNRESET on compressed response', async ({ context, server }) => {
+ let requestCount = 0;
+ server.setRoute('/test-gzip', (req, res) => {
+ if (requestCount++ < 2) {
+ req.socket.destroy();
+ return;
+ }
+ res.writeHead(200, {
+ 'Content-Encoding': 'gzip',
+ 'Content-Type': 'text/plain',
+ });
+ const gzipStream = zlib.createGzip();
+ pipeline(gzipStream, res, err => {
+ if (err)
+ console.log(`Server error: ${err}`);
+ });
+ gzipStream.write('compressed-retry-ok');
+ gzipStream.end();
+ });
+ const response = await context.request.get(server.PREFIX + '/test-gzip', { maxRetries: 3 });
+ expect(response.status()).toBe(200);
+ expect(await response.text()).toBe('compressed-retry-ok');
+ expect(requestCount).toBe(3);
+});
+
+it('should retry ECONNRESET mid-stream during gzip decompression', async ({ context, server }) => {
+ let requestCount = 0;
+ server.setRoute('/test-gzip-midstream', (req, res) => {
+ requestCount++;
+ if (requestCount <= 2) {
+ // Send response headers to make client enter the decompression pipeline,
+ // then destroy the socket. This exercises the fix: without it, the
+ // pipeline error callback wraps the error, stripping .code for retry.
+ res.writeHead(200, {
+ 'Content-Encoding': 'gzip',
+ 'Content-Type': 'text/plain',
+ });
+ res.flushHeaders();
+ req.socket.destroy();
+ return;
+ }
+ res.writeHead(200, {
+ 'Content-Encoding': 'gzip',
+ 'Content-Type': 'text/plain',
+ });
+ const gzipStream = zlib.createGzip();
+ pipeline(gzipStream, res, err => {
+ if (err)
+ console.log(`Server error: ${err}`);
+ });
+ gzipStream.write('midstream-retry-ok');
+ gzipStream.end();
+ });
+ const response = await context.request.get(server.PREFIX + '/test-gzip-midstream', { maxRetries: 3 });
+ expect(response.status()).toBe(200);
+ expect(await response.text()).toBe('midstream-retry-ok');
+ expect(requestCount).toBe(3);
+});
diff --git a/utils/build/build-playwright-driver.sh b/utils/build/build-playwright-driver.sh
index 10acdd5af58f0..11c25f6f428b7 100755
--- a/utils/build/build-playwright-driver.sh
+++ b/utils/build/build-playwright-driver.sh
@@ -4,7 +4,7 @@ set -x
trap "cd $(pwd -P)" EXIT
SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)"
-NODE_VERSION="24.15.0" # autogenerated via ./update-playwright-node.mjs
+NODE_VERSION="24.16.0" # autogenerated via ./update-playwright-node.mjs
cd "$(dirname "$0")"
PACKAGE_VERSION=$(node -p "require('../../package.json').version")