diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index dcdca4705a..8155e3d60d 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -503,6 +503,12 @@ If set changes the request URL. New URL must have same protocol as original one. Maximum number of request redirects that will be followed automatically. An error will be thrown if the number is exceeded. Defaults to `20`. Pass `0` to not follow redirects. +### option: Route.fetch.maxRetries +* since: v1.46 +- `maxRetries` <[int]> + +Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. + ### option: Route.fetch.timeout * since: v1.33 - `timeout` <[float]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 78d62060cc..5a3a17d97e 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -524,9 +524,9 @@ Does not enforce fixed viewport, allows resizing window in the headed mode. ## context-option-clientCertificates - `clientCertificates` <[Array]<[Object]>> - `origin` <[string]> Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. - - `certPath` ?<[string]> Path to the file with the certificate in PEM format. - - `keyPath` ?<[string]> Path to the file with the private key in PEM format. - - `pfxPath` ?<[string]> Path to the PFX or PKCS12 encoded private key and certificate chain. + - `certPath` ?<[path]> Path to the file with the certificate in PEM format. + - `keyPath` ?<[path]> Path to the file with the private key in PEM format. + - `pfxPath` ?<[path]> Path to the PFX or PKCS12 encoded private key and certificate chain. - `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX). TLS Client Authentication allows the server to request a client certificate and verify it. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 7026f64555..cfcc0305d9 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19570,6 +19570,12 @@ export interface Route { */ maxRedirects?: number; + /** + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * If set changes the request method (e.g. GET or POST). */ diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index 5915272621..b3bea9e432 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -17,9 +17,15 @@ import os from 'os'; import * as util from 'util'; import { getPlaywrightVersion } from '../../packages/playwright-core/lib/utils/userAgent'; -import { expect, playwrightTest as it } from '../config/browserTest'; +import { expect, playwrightTest as base } from '../config/browserTest'; import { kTargetClosedErrorMessage } from 'tests/config/errors'; +const it = base.extend({ + context: async () => { + throw new Error('global fetch tests should not use context'); + } +}); + it.skip(({ mode }) => mode !== 'default'); for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] as const) { @@ -33,9 +39,11 @@ for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] expect(response.headers()['content-type']).toBe('application/json; charset=utf-8'); expect(response.headersArray()).toContainEqual({ name: 'Content-Type', value: 'application/json; charset=utf-8' }); expect(await response.text()).toBe('head' === method ? '' : '{"foo": "bar"}\n'); + await request.dispose(); }); } + it(`should dispose global request`, async function({ playwright, server }) { const request = await playwright.request.newContext(); const response = await request.get(server.PREFIX + '/simple.json'); @@ -43,6 +51,7 @@ it(`should dispose global request`, async function({ playwright, server }) { await request.dispose(); const error = await response.body().catch(e => e); expect(error.message).toContain('Response has been disposed'); + await request.dispose(); }); it('should support global userAgent option', async ({ playwright, server }) => { @@ -54,6 +63,7 @@ it('should support global userAgent option', async ({ playwright, server }) => { expect(response.ok()).toBeTruthy(); expect(response.url()).toBe(server.EMPTY_PAGE); expect(serverRequest.headers['user-agent']).toBe('My Agent'); + await request.dispose(); }); it('should support global timeout option', async ({ playwright, server }) => { @@ -61,6 +71,7 @@ it('should support global timeout option', async ({ playwright, server }) => { server.setRoute('/empty.html', (req, res) => {}); const error = await request.get(server.EMPTY_PAGE).catch(e => e); expect(error.message).toContain('Request timed out after 100ms'); + await request.dispose(); }); it('should propagate extra http headers with redirects', async ({ playwright, server }) => { @@ -76,6 +87,7 @@ it('should propagate extra http headers with redirects', async ({ playwright, se expect(req1.headers['my-secret']).toBe('Value'); expect(req2.headers['my-secret']).toBe('Value'); expect(req3.headers['my-secret']).toBe('Value'); + await request.dispose(); }); it('should support global httpCredentials option', async ({ playwright, server }) => { @@ -96,6 +108,7 @@ it('should return error with wrong credentials', async ({ playwright, server }) const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'wrong' } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should work with correct credentials and matching origin', async ({ playwright, server }) => { @@ -103,6 +116,7 @@ it('should work with correct credentials and matching origin', async ({ playwrig const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should work with correct credentials and matching origin case insensitive', async ({ playwright, server }) => { @@ -110,6 +124,7 @@ it('should work with correct credentials and matching origin case insensitive', const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase() } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should return error with correct credentials and mismatching scheme', async ({ playwright, server }) => { @@ -117,6 +132,7 @@ it('should return error with correct credentials and mismatching scheme', async const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should return error with correct credentials and mismatching hostname', async ({ playwright, server }) => { @@ -126,6 +142,7 @@ it('should return error with correct credentials and mismatching hostname', asyn const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should return error with correct credentials and mismatching port', async ({ playwright, server }) => { @@ -134,6 +151,7 @@ it('should return error with correct credentials and mismatching port', async ({ const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { @@ -152,6 +170,7 @@ it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); expect(credentials).toBe('user:pass'); + await request.dispose(); }); it('should support HTTPCredentials.send', async ({ playwright, server }) => { @@ -176,12 +195,14 @@ it('should support HTTPCredentials.send', async ({ playwright, server }) => { expect(serverRequest.headers.authorization).toBe(undefined); expect(response.status()).toBe(200); } + await request.dispose(); }); it('should support global ignoreHTTPSErrors option', async ({ playwright, httpsServer }) => { const request = await playwright.request.newContext({ ignoreHTTPSErrors: true }); const response = await request.get(httpsServer.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, httpsServer }) => { @@ -189,12 +210,14 @@ it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, https const request = await playwright.request.newContext(); const response = await request.get(httpsServer.PREFIX + '/redir', { ignoreHTTPSErrors: true }); expect(response.status()).toBe(200); + await request.dispose(); }); it('should resolve url relative to global baseURL option', async ({ playwright, server }) => { const request = await playwright.request.newContext({ baseURL: server.PREFIX }); const response = await request.get('/empty.html'); expect(response.url()).toBe(server.EMPTY_PAGE); + await request.dispose(); }); it('should set playwright as user-agent', async ({ playwright, server, isWindows, isLinux, isMac }) => { @@ -221,12 +244,14 @@ it('should set playwright as user-agent', async ({ playwright, server, isWindows expect(userAgentMasked.replace(/; \w+ [^)]+/, '; distro version')).toBe('Playwright/X.X.X (; distro version) node/X.X' + suffix); else if (isMac) expect(userAgentMasked).toBe('Playwright/X.X.X (; macOS X.X) node/X.X' + suffix); + await request.dispose(); }); it('should be able to construct with context options', async ({ playwright, browserType, server }) => { const request = await playwright.request.newContext((browserType as any)._defaultContextOptions); const response = await request.get(server.EMPTY_PAGE); expect(response.ok()).toBeTruthy(); + await request.dispose(); }); it('should return empty body', async ({ playwright, server }) => { @@ -254,6 +279,7 @@ it('should abort requests when context is disposed', async ({ playwright, server expect(result.message).toContain(kTargetClosedErrorMessage); } await connectionClosed; + await request.dispose(); }); it('should abort redirected requests when context is disposed', async ({ playwright, server }) => { @@ -269,6 +295,7 @@ it('should abort redirected requests when context is disposed', async ({ playwri expect(result instanceof Error).toBeTruthy(); expect(result.message).toContain(kTargetClosedErrorMessage); await connectionClosed; + await request.dispose(); }); it('should remove content-length from redirected post requests', async ({ playwright, server }) => { @@ -473,7 +500,6 @@ it('should serialize post data on the client', async ({ playwright, server }) => await postReq; const body = await (await serverReq).postBody; expect(body.toString()).toBe('{"foo":"bar"}'); - // expect(serverRequest.rawHeaders).toContain('vaLUE'); await request.dispose(); }); @@ -486,7 +512,8 @@ it('should throw after dispose', async ({ playwright, server }) => { it('should retry ECONNRESET', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' } -}, async ({ context, server }) => { +}, async ({ playwright, server }) => { + const request = await playwright.request.newContext(); let requestCount = 0; server.setRoute('/test', (req, res) => { if (requestCount++ < 3) { @@ -496,8 +523,9 @@ it('should retry ECONNRESET', { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('Hello!'); }); - const response = await context.request.fetch(server.PREFIX + '/test', { maxRetries: 3 }); + const response = await request.fetch(server.PREFIX + '/test', { maxRetries: 3 }); expect(response.status()).toBe(200); expect(await response.text()).toBe('Hello!'); expect(requestCount).toBe(4); + await request.dispose(); }); diff --git a/utils/doclint/generateDotnetApi.js b/utils/doclint/generateDotnetApi.js index 9c094026f9..aa8210f619 100644 --- a/utils/doclint/generateDotnetApi.js +++ b/utils/doclint/generateDotnetApi.js @@ -400,10 +400,18 @@ function generateNameDefault(member, name, t, parent) { if (names[2] === names[1]) names.pop(); // get rid of duplicates, cheaply let attemptedName = names.pop(); - const typesDiffer = function(left, right) { + const typesDiffer = function(/** @type {Documentation.Type} */ left, /** @type {Documentation.Type} */ right) { if (left.expression && right.expression) return left.expression !== right.expression; - return JSON.stringify(right.properties) !== JSON.stringify(left.properties); + const toExpression = (/** @type {Documentation.Member} */ t) => t.name + t.type?.expression; + const leftOverRightProperties = new Set(left.properties?.map(toExpression) ?? []); + for (const prop of right.properties ?? []) { + const expression = toExpression(prop); + if (!leftOverRightProperties.has(expression)) + return true; + leftOverRightProperties.delete(expression); + } + return leftOverRightProperties.size > 0; }; while (true) { // crude attempt at removing plurality