diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 7101d1996d..dab99bec2b 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -344,6 +344,9 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ ### option: APIRequestContext.fetch.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% * since: v1.26 +### option: APIRequestContext.fetch.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%% +* since: v1.46 + ## async method: APIRequestContext.get * since: v1.16 - returns: <[APIResponse]> @@ -433,6 +436,9 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query ### option: APIRequestContext.get.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% * since: v1.26 +### option: APIRequestContext.get.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%% +* since: v1.46 + ## async method: APIRequestContext.head * since: v1.16 - returns: <[APIResponse]> @@ -486,6 +492,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.head.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% * since: v1.26 +### option: APIRequestContext.head.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%% +* since: v1.46 + ## async method: APIRequestContext.patch * since: v1.16 - returns: <[APIResponse]> @@ -539,6 +548,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.patch.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% * since: v1.26 +### option: APIRequestContext.patch.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%% +* since: v1.46 + ## async method: APIRequestContext.post * since: v1.16 - returns: <[APIResponse]> @@ -713,6 +725,9 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar ### option: APIRequestContext.post.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% * since: v1.26 +### option: APIRequestContext.post.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%% +* since: v1.46 + ## async method: APIRequestContext.put * since: v1.16 - returns: <[APIResponse]> @@ -766,6 +781,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.put.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% * since: v1.26 +### option: APIRequestContext.put.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%% +* since: v1.46 + ## async method: APIRequestContext.storageState * since: v1.16 - returns: <[Object]> diff --git a/docs/src/api/class-requestoptions.md b/docs/src/api/class-requestoptions.md index b6eeb8970a..401e13d944 100644 --- a/docs/src/api/class-requestoptions.md +++ b/docs/src/api/class-requestoptions.md @@ -126,6 +126,16 @@ Whether to ignore HTTPS errors when sending network requests. 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. +## method: RequestOptions.setMaxRetries +* since: v1.46 +- returns: <[RequestOptions]> + +### param: RequestOptions.setMaxRetries.maxRetries +* since: v1.46 +- `maxRetries` <[int]> + +Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. + ## method: RequestOptions.setMethod * since: v1.18 - returns: <[RequestOptions]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index a1dd84c4ef..253ca3a1fd 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -458,6 +458,12 @@ Whether to ignore HTTPS errors when sending network requests. Defaults to `false 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. +## js-python-csharp-fetch-option-maxretries +* langs: js, python, csharp +- `maxRetries` <[int]> + +Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. + ## evaluate-expression - `expression` <[string]> diff --git a/docs/src/dialogs.md b/docs/src/dialogs.md index 1d8938778c..20037245ee 100644 --- a/docs/src/dialogs.md +++ b/docs/src/dialogs.md @@ -5,7 +5,7 @@ title: "Dialogs" ## Introduction -Playwright can interact with the web page dialogs such as [`alert`](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), [`confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), [`prompt`](https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt) as well as [`beforeunload`](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) confirmation. +Playwright can interact with the web page dialogs such as [`alert`](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), [`confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), [`prompt`](https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt) as well as [`beforeunload`](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) confirmation. For print dialogs, see [Print](#print-dialogs). ## alert(), confirm(), prompt() dialogs @@ -126,3 +126,55 @@ Page.Dialog += async (_, dialog) => }; await Page.CloseAsync(new() { RunBeforeUnload = true }); ``` + +## Print dialogs + +In order to assert that a print dialog via [`window.print`](https://developer.mozilla.org/en-US/docs/Web/API/Window/print) was triggered, you can use the following snippet: + +```js +await page.goto(''); + +await page.evaluate('(() => {window.waitForPrintDialog = new Promise(f => window.print = f);})()'); +await page.getByText('Print it!').click(); + +await page.waitForFunction('window.waitForPrintDialog'); +``` + +```java +page.navigate(""); + +page.evaluate("(() => {window.waitForPrintDialog = new Promise(f => window.print = f);})()"); +page.getByText("Print it!").click(); + +page.waitForFunction("window.waitForPrintDialog"); +``` + +```python async +await page.goto("") + +await page.evaluate("(() => {window.waitForPrintDialog = new Promise(f => window.print = f);})()") +await page.get_by_text("Print it!").click() + +await page.wait_for_function("window.waitForPrintDialog") +``` + +```python sync +page.goto("") + +page.evaluate("(() => {window.waitForPrintDialog = new Promise(f => window.print = f);})()") +page.get_by_text("Print it!").click() + +page.wait_for_function("window.waitForPrintDialog") +``` + +```csharp +await Page.GotoAsync(""); + +await Page.EvaluateAsync("(() => {window.waitForPrintDialog = new Promise(f => window.print = f);})()"); +await Page.GetByText("Print it!").ClickAsync(); + +await Page.WaitForFunctionAsync("window.waitForPrintDialog"); +``` + +This will wait for the print dialog to be opened after the button is clicked. +Make sure to evaluate the script before clicking the button / after the page is loaded. diff --git a/docs/src/test-parameterize-js.md b/docs/src/test-parameterize-js.md index e6cc791207..8be8009549 100644 --- a/docs/src/test-parameterize-js.md +++ b/docs/src/test-parameterize-js.md @@ -9,13 +9,61 @@ You can either parameterize tests on a test level or on a project level. ## Parameterized Tests ```js title="example.spec.ts" -const people = ['Alice', 'Bob']; -for (const name of people) { - test(`testing with ${name}`, async () => { - // ... - }); +[ + { name: 'Alice', expected: 'Hello, Alice!' }, + { name: 'Bob', expected: 'Hello, Bob!' }, + { name: 'Charlie', expected: 'Hello, Charlie!' }, +].forEach(({ name, expected }) => { // You can also do it with test.describe() or with multiple tests as long the test name is unique. -} + test(`testing with ${name}`, async ({ page }) => { + await page.goto(`https://example.com/greet?name=${name}`); + await expect(page.getByRole('heading')).toHaveText(expected); + }); +}); +``` + +### Before and after hooks + +Most of the time you should put `beforeEach`, `beforeAll`, `afterEach` and `afterAll` hooks outside of `forEach`, so that hooks are executed just once: + +```js title="example.spec.ts" +test.beforeEach(async ({ page }) => { + // ... +}); + +test.afterEach(async ({ page }) => { + // ... +}); + +[ + { name: 'Alice', expected: 'Hello, Alice!' }, + { name: 'Bob', expected: 'Hello, Bob!' }, + { name: 'Charlie', expected: 'Hello, Charlie!' }, +].forEach(({ name, expected }) => { + test(`testing with ${name}`, async ({ page }) => { + await page.goto(`https://example.com/greet?name=${name}`); + await expect(page.getByRole('heading')).toHaveText(expected); + }); +}); +``` + +If you want to have hooks for each test, you can put them inside a `describe()` - so they are executed for each iteration / each invidual test: + +```js title="example.spec.ts" +[ + { name: 'Alice', expected: 'Hello, Alice!' }, + { name: 'Bob', expected: 'Hello, Bob!' }, + { name: 'Charlie', expected: 'Hello, Charlie!' }, +].forEach(({ name, expected }) => { + test.describe(() => { + test.beforeEach(async ({ page }) => { + await page.goto(`https://example.com/greet?name=${name}`); + }); + test(`testing with ${expected}`, async ({ page }) => { + await expect(page.getByRole('heading')).toHaveText(expected); + }); + }); +}); ``` ## Parameterized Projects diff --git a/package-lock.json b/package-lock.json index 1a3e299839..acd9467df5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,7 @@ "ssim.js": "^3.5.0", "typescript": "^5.3.2", "vite": "^5.0.13", - "ws": "^8.5.0", + "ws": "^8.17.1", "xml2js": "^0.5.0", "yaml": "^2.2.2" }, @@ -8028,9 +8028,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 6935a8a999..125abb0b7c 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "ssim.js": "^3.5.0", "typescript": "^5.3.2", "vite": "^5.0.13", - "ws": "^8.5.0", + "ws": "^8.17.1", "xml2js": "^0.5.0", "yaml": "^2.2.2" } diff --git a/packages/playwright-core/ThirdPartyNotices.txt b/packages/playwright-core/ThirdPartyNotices.txt index f0b678af04..3c5a71e20f 100644 --- a/packages/playwright-core/ThirdPartyNotices.txt +++ b/packages/playwright-core/ThirdPartyNotices.txt @@ -46,7 +46,7 @@ This project incorporates components from the projects listed below. The origina - sprintf-js@1.1.3 (https://github.com/alexei/sprintf.js) - stack-utils@2.0.5 (https://github.com/tapjs/stack-utils) - wrappy@1.0.2 (https://github.com/npm/wrappy) -- ws@8.4.2 (https://github.com/websockets/ws) +- ws@8.17.1 (https://github.com/websockets/ws) - yauzl@2.10.0 (https://github.com/thejoshwolfe/yauzl) - yazl@2.5.1 (https://github.com/thejoshwolfe/yazl) @@ -1435,29 +1435,30 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ========================================= END OF wrappy@1.0.2 AND INFORMATION -%% ws@8.4.2 NOTICES AND INFORMATION BEGIN HERE +%% ws@8.17.1 NOTICES AND INFORMATION BEGIN HERE ========================================= Copyright (c) 2011 Einar Otto Stangvik +Copyright (c) 2013 Arnout Kazemier and contributors +Copyright (c) 2016 Luigi Pinca and contributors -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= -END OF ws@8.4.2 AND INFORMATION +END OF ws@8.17.1 AND INFORMATION %% yauzl@2.10.0 NOTICES AND INFORMATION BEGIN HERE ========================================= diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index ce21ea45a0..d21d1c67e7 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,19 +15,19 @@ }, { "name": "firefox", - "revision": "1454", + "revision": "1456", "installByDefault": true, "browserVersion": "127.0" }, { "name": "firefox-beta", - "revision": "1454", + "revision": "1456", "installByDefault": false, "browserVersion": "128.0b3" }, { "name": "webkit", - "revision": "2035", + "revision": "2036", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/bundles/utils/package-lock.json b/packages/playwright-core/bundles/utils/package-lock.json index df01638322..66c4cdae12 100644 --- a/packages/playwright-core/bundles/utils/package-lock.json +++ b/packages/playwright-core/bundles/utils/package-lock.json @@ -24,7 +24,7 @@ "signal-exit": "3.0.7", "socks-proxy-agent": "6.1.1", "stack-utils": "2.0.5", - "ws": "8.4.2" + "ws": "8.17.1" }, "devDependencies": { "@types/debug": "^4.1.7", @@ -399,15 +399,15 @@ } }, "node_modules/ws": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz", - "integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -702,9 +702,9 @@ } }, "ws": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz", - "integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "requires": {} } } diff --git a/packages/playwright-core/bundles/utils/package.json b/packages/playwright-core/bundles/utils/package.json index 786b544a97..8ac0c112fe 100644 --- a/packages/playwright-core/bundles/utils/package.json +++ b/packages/playwright-core/bundles/utils/package.json @@ -25,7 +25,7 @@ "signal-exit": "3.0.7", "socks-proxy-agent": "6.1.1", "stack-utils": "2.0.5", - "ws": "8.4.2" + "ws": "8.17.1" }, "devDependencies": { "@types/debug": "^4.1.7", diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 4598ac2418..44314c6fa3 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -41,6 +41,7 @@ export type FetchOptions = { failOnStatusCode?: boolean, ignoreHTTPSErrors?: boolean, maxRedirects?: number, + maxRetries?: number, }; type NewContextOptions = Omit & { @@ -168,11 +169,11 @@ export class APIRequestContext extends ChannelOwner= 0, `'maxRedirects' should be greater than or equal to '0'`); + assert(options.maxRedirects === undefined || options.maxRedirects >= 0, `'maxRedirects' must be greater than or equal to '0'`); + assert(options.maxRetries === undefined || options.maxRetries >= 0, `'maxRetries' must be greater than or equal to '0'`); const url = options.url !== undefined ? options.url : options.request!.url(); const params = objectToArray(options.params); const method = options.method || options.request?.method(); - const maxRedirects = options.maxRedirects; // Cannot call allHeaders() here as the request may be paused inside route handler. const headersObj = options.headers || options.request?.headers() ; const headers = headersObj ? headersObjectToArray(headersObj) : undefined; @@ -234,7 +235,8 @@ export class APIRequestContext extends ChannelOwner implements api.Ro }); } - async fetch(options: FallbackOverrides & { maxRedirects?: number, timeout?: number } = {}): Promise { + async fetch(options: FallbackOverrides & { maxRedirects?: number, maxRetries?: number, timeout?: number } = {}): Promise { return await this._wrapApiCall(async () => { return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options }); }); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 5727192b59..9187dc13d2 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -182,6 +182,7 @@ scheme.APIRequestContextFetchParams = tObject({ failOnStatusCode: tOptional(tBoolean), ignoreHTTPSErrors: tOptional(tBoolean), maxRedirects: tOptional(tNumber), + maxRetries: tOptional(tNumber), }); scheme.APIRequestContextFetchResult = tObject({ response: tType('APIResponse'), diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 0f87e0046b..c02781f748 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -201,7 +201,7 @@ export abstract class APIRequestContext extends SdkObject { setHeader(headers, 'content-length', String(postData.byteLength)); const controller = new ProgressController(metadata, this); const fetchResponse = await controller.run(progress => { - return this._sendRequest(progress, requestUrl, options, postData); + return this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries); }); const fetchUid = this._storeResponseBody(fetchResponse.body); this.fetchLog.set(fetchUid, controller.metadata.log); @@ -247,6 +247,28 @@ export abstract class APIRequestContext extends SdkObject { } } + private async _sendRequestWithRetries(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer, maxRetries?: number): Promise & { body: Buffer }>{ + maxRetries ??= 0; + let backoff = 250; + for (let i = 0; i <= maxRetries; i++) { + try { + return await this._sendRequest(progress, url, options, postData); + } catch (e) { + if (maxRetries === 0) + throw e; + if (i === maxRetries || (options.deadline && monotonicTime() + backoff > options.deadline)) + throw new Error(`Failed after ${i + 1} attempt(s): ${e}`); + // Retry on connection reset only. + if (e.code !== 'ECONNRESET') + throw e; + progress.log(` Received ECONNRESET, will retry after ${backoff}ms.`); + await new Promise(f => setTimeout(f, backoff)); + backoff *= 2; + } + } + throw new Error('Unreachable'); + } + private async _sendRequest(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise & { body: Buffer }>{ await this._updateRequestCookieHeader(url, options.headers); diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 289a4f6d87..268da9e84d 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -434,12 +434,6 @@ export class HarTracer { const pageEntry = this._createPageEntryIfNeeded(page); const request = response.request(); - // Prefer "response received" time over "request sent" time - // for the purpose of matching requests that were used in a particular snapshot. - // Note that both snapshot time and request time are taken here in the Node process. - if (this._options.includeTraceInfo) - harEntry._monotonicTime = monotonicTime(); - harEntry.response = { status: response.status(), statusText: response.statusText(), diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index 389ce6b7ac..ba9ba9e5d3 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -2122,6 +2122,10 @@ export module Protocol { * Array of DOMNode ids of any children marked as selected. */ selectedChildNodeIds?: NodeId[]; + /** + * On / off state of switch form controls. + */ + switchState?: "off"|"on"; } /** * A structure holding an RGBA color. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 28250cb271..67e92a917a 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -15963,6 +15963,12 @@ export interface APIRequestContext { */ maxRedirects?: number; + /** + * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error + * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) or * [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST)). If not specified, GET method is used. @@ -16063,6 +16069,12 @@ export interface APIRequestContext { */ maxRedirects?: number; + /** + * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error + * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless @@ -16143,6 +16155,12 @@ export interface APIRequestContext { */ maxRedirects?: number; + /** + * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error + * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless @@ -16223,6 +16241,12 @@ export interface APIRequestContext { */ maxRedirects?: number; + /** + * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error + * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless @@ -16345,6 +16369,12 @@ export interface APIRequestContext { */ maxRedirects?: number; + /** + * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error + * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless @@ -16425,6 +16455,12 @@ export interface APIRequestContext { */ maxRedirects?: number; + /** + * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error + * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless diff --git a/packages/playwright/src/fsWatcher.ts b/packages/playwright/src/fsWatcher.ts index c0d4e4d04e..a1c09c343d 100644 --- a/packages/playwright/src/fsWatcher.ts +++ b/packages/playwright/src/fsWatcher.ts @@ -16,7 +16,6 @@ import { chokidar } from './utilsBundle'; import type { FSWatcher } from 'chokidar'; -import path from 'path'; export type FSEvent = { event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', file: string }; @@ -52,7 +51,7 @@ export class Watcher { if (!this._watchedFiles.length) return; - const ignored = [...this._ignoredFolders, (name: string) => name.includes(path.sep + 'node_modules' + path.sep)]; + const ignored = [...this._ignoredFolders, '**/node_modules/**']; this._fsWatcher = chokidar.watch(watchedFiles, { ignoreInitial: true, ignored }).on('all', async (event, file) => { if (this._throttleTimer) clearTimeout(this._throttleTimer); diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 13d34f1f51..ffb591556c 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -324,6 +324,7 @@ export type APIRequestContextFetchParams = { failOnStatusCode?: boolean, ignoreHTTPSErrors?: boolean, maxRedirects?: number, + maxRetries?: number, }; export type APIRequestContextFetchOptions = { params?: NameValue[], @@ -337,6 +338,7 @@ export type APIRequestContextFetchOptions = { failOnStatusCode?: boolean, ignoreHTTPSErrors?: boolean, maxRedirects?: number, + maxRetries?: number, }; export type APIRequestContextFetchResult = { response: APIResponse, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 2bef7c8762..14799ef17e 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -299,6 +299,7 @@ APIRequestContext: failOnStatusCode: boolean? ignoreHTTPSErrors: boolean? maxRedirects: number? + maxRetries: number? returns: response: APIResponse diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index ba866ad7ca..94d42ba4a8 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -285,6 +285,10 @@ function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: numbe for (const frame of page.screencastFrames) frame.timestamp += monotonicTimeDelta; } + for (const resource of context.resources) { + if (resource._monotonicTime) + resource._monotonicTime += monotonicTimeDelta; + } } } diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index ba01ccc07c..a8aaeb389f 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -1287,3 +1287,21 @@ it('should not work after context dispose', async ({ context, server }) => { await context.close({ reason: 'Test ended.' }); expect(await context.request.get(server.EMPTY_PAGE).catch(e => e.message)).toContain('Test ended.'); }); + +it('should retrty ECONNRESET', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' } +}, async ({ context, server }) => { + let requestCount = 0; + server.setRoute('/test', (req, res) => { + if (requestCount++ < 3) { + req.socket.destroy(); + return; + } + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('Hello!'); + }); + const response = await context.request.get(server.PREFIX + '/test', { maxRetries: 3 }); + expect(response.status()).toBe(200); + expect(await response.text()).toBe('Hello!'); + expect(requestCount).toBe(4); +}); diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index a2eb629dc5..264df5c619 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -436,7 +436,7 @@ it('should throw an error when maxRedirects is less than 0', async ({ playwright const request = await playwright.request.newContext(); for (const method of ['GET', 'PUT', 'POST', 'OPTIONS', 'HEAD', 'PATCH']) - await expect(async () => request.fetch(`${server.PREFIX}/a/redirect1`, { method, maxRedirects: -1 })).rejects.toThrow(`'maxRedirects' should be greater than or equal to '0'`); + await expect(async () => request.fetch(`${server.PREFIX}/a/redirect1`, { method, maxRedirects: -1 })).rejects.toThrow(`'maxRedirects' must be greater than or equal to '0'`); await request.dispose(); }); @@ -483,3 +483,21 @@ it('should throw after dispose', async ({ playwright, server }) => { await request.dispose(); await expect(request.get(server.EMPTY_PAGE)).rejects.toThrow('Target page, context or browser has been closed'); }); + +it('should retry ECONNRESET', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' } +}, async ({ context, server }) => { + let requestCount = 0; + server.setRoute('/test', (req, res) => { + if (requestCount++ < 3) { + req.socket.destroy(); + return; + } + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('Hello!'); + }); + const response = await context.request.fetch(server.PREFIX + '/test', { maxRetries: 3 }); + expect(response.status()).toBe(200); + expect(await response.text()).toBe('Hello!'); + expect(requestCount).toBe(4); +}); diff --git a/tests/library/playwright.config.ts b/tests/library/playwright.config.ts index a70c1f5e6d..8cb0347a1a 100644 --- a/tests/library/playwright.config.ts +++ b/tests/library/playwright.config.ts @@ -128,7 +128,13 @@ for (const browserName of browserNames) { metadata: { platform: process.platform, docker: !!process.env.INSIDE_DOCKER, - headful: !!headed, + headless: (() => { + if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW) + return 'headless-new'; + if (headed) + return 'headed'; + return 'headless'; + })(), browserName, channel, mode, diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index c2634df148..69864380d8 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -1236,3 +1236,38 @@ test('should open snapshot in new browser context', async ({ browser, page, runA await expect(newPage.getByText('hello')).toBeVisible(); await newPage.close(); }); + +function parseMillis(s: string): number { + const matchMs = s.match(/(\d+)ms/); + if (matchMs) + return +matchMs[1]; + const matchSeconds = s.match(/([\d.]+)s/); + if (!matchSeconds) + throw new Error('Failed to parse to millis: ' + s); + return (+matchSeconds[1]) * 1000; +} + +test('should show correct request start time', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31133' }, +}, async ({ page, runAndTrace, server }) => { + server.setRoute('/api', (req, res) => { + setTimeout(() => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('done'); + }, 1100); + }); + const traceViewer = await runAndTrace(async () => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + return fetch('/api').then(r => r.text()); + }); + }); + await traceViewer.selectAction('page.evaluate'); + await traceViewer.showNetworkTab(); + await expect(traceViewer.networkRequests).toContainText([/apiGET200text/]); + const line = traceViewer.networkRequests.getByText(/apiGET200text/); + const start = await line.locator('.grid-view-column-start').textContent(); + const duration = await line.locator('.grid-view-column-duration').textContent(); + expect(parseMillis(duration)).toBeGreaterThan(1000); + expect(parseMillis(start)).toBeLessThan(1000); +}); diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index 5ceae2f04e..39dcaecbc6 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -206,9 +206,8 @@ it.describe('screencast', () => { expectRedFrames(videoFile, size); }); - it('should continue recording main page after popup closes', async ({ browser, browserName, trace, headless, isWindows }, testInfo) => { + it('should continue recording main page after popup closes', async ({ browser, browserName }, testInfo) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30837' }); - it.fixme(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/30837'); // Firefox does not have a mobile variant and has a large minimum size (500 on windows and 450 elsewhere). const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; const context = await browser.newContext({ diff --git a/tests/page/page-emulate-media.spec.ts b/tests/page/page-emulate-media.spec.ts index d1ba297ae4..c895d64eff 100644 --- a/tests/page/page-emulate-media.spec.ts +++ b/tests/page/page-emulate-media.spec.ts @@ -124,9 +124,8 @@ it('should emulate reduced motion', async ({ page }) => { await page.emulateMedia({ reducedMotion: null }); }); -it('should keep reduced motion and color emulation after reload', async ({ page, server, browserName }) => { +it('should keep reduced motion and color emulation after reload', async ({ page, server }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31328' }); - it.fixme(browserName === 'firefox'); // Pre-conditions expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toEqual(false);