Merge branch 'microsoft:main' into main

This commit is contained in:
ryanrosello-og 2024-06-20 18:23:33 +10:00 committed by GitHub
commit 251028ff10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 333 additions and 58 deletions

View file

@ -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]>

View file

@ -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]>

View file

@ -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]>

View file

@ -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('<url>');
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("<url>");
page.evaluate("(() => {window.waitForPrintDialog = new Promise(f => window.print = f);})()");
page.getByText("Print it!").click();
page.waitForFunction("window.waitForPrintDialog");
```
```python async
await page.goto("<url>")
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("<url>")
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("<url>");
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.

View file

@ -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

8
package-lock.json generated
View file

@ -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"

View file

@ -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"
}

View file

@ -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 <einaros@gmail.com>
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
=========================================

View file

@ -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",

View file

@ -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": {}
}
}

View file

@ -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",

View file

@ -41,6 +41,7 @@ export type FetchOptions = {
failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean,
maxRedirects?: number,
maxRetries?: number,
};
type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'storageState' | 'tracesDir'> & {
@ -168,11 +169,11 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
throw new TargetClosedError(this._closeReason);
assert(options.request || typeof options.url === 'string', 'First argument must be either URL string or Request');
assert((options.data === undefined ? 0 : 1) + (options.form === undefined ? 0 : 1) + (options.multipart === undefined ? 0 : 1) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`);
assert(options.maxRedirects === undefined || options.maxRedirects >= 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<channels.APIRequestContextCh
timeout: options.timeout,
failOnStatusCode: options.failOnStatusCode,
ignoreHTTPSErrors: options.ignoreHTTPSErrors,
maxRedirects: maxRedirects,
maxRedirects: options.maxRedirects,
maxRetries: options.maxRetries,
...fixtures
});
return new APIResponse(this, result.response);

View file

@ -336,7 +336,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
});
}
async fetch(options: FallbackOverrides & { maxRedirects?: number, timeout?: number } = {}): Promise<APIResponse> {
async fetch(options: FallbackOverrides & { maxRedirects?: number, maxRetries?: number, timeout?: number } = {}): Promise<APIResponse> {
return await this._wrapApiCall(async () => {
return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options });
});

View file

@ -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'),

View file

@ -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<Omit<channels.APIResponse, 'fetchUid'> & { 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<Omit<channels.APIResponse, 'fetchUid'> & { body: Buffer }>{
await this._updateRequestCookieHeader(url, options.headers);

View file

@ -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(),

View file

@ -2122,6 +2122,10 @@ export module Protocol {
* Array of <code>DOMNode</code> 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.

View file

@ -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

View file

@ -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);

View file

@ -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,

View file

@ -299,6 +299,7 @@ APIRequestContext:
failOnStatusCode: boolean?
ignoreHTTPSErrors: boolean?
maxRedirects: number?
maxRetries: number?
returns:
response: APIResponse

View file

@ -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;
}
}
}

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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,

View file

@ -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);
});

View file

@ -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({

View file

@ -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);