Merge branch 'main' into sharding-algorithm

This commit is contained in:
Mathias Leppich 2024-09-16 12:31:02 +02:00
commit b3b568bc31
70 changed files with 676 additions and 432 deletions

View file

@ -256,6 +256,13 @@ class PageHandler {
return await this._contentPage.send('disposeObject', options);
}
async ['Heap.collectGarbage']() {
Services.obs.notifyObservers(null, "child-gc-request");
Cu.forceGC();
Services.obs.notifyObservers(null, "child-cc-request");
Cu.forceCC();
}
async ['Network.getResponseBody']({requestId}) {
return this._pageNetwork.getResponseBody(requestId);
}

View file

@ -487,6 +487,17 @@ const Browser = {
},
};
const Heap = {
targets: ['page'],
types: {},
events: {},
methods: {
'collectGarbage': {
params: {},
},
},
};
const Network = {
targets: ['page'],
types: networkTypes,
@ -1002,7 +1013,7 @@ const Accessibility = {
}
this.protocol = {
domains: {Browser, Page, Runtime, Network, Accessibility},
domains: {Browser, Heap, Page, Runtime, Network, Accessibility},
};
this.checkScheme = checkScheme;
this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme'];

View file

@ -159,7 +159,10 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.delete.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.17
### option: APIRequestContext.delete.form = %%-js-python-fetch-option-form-%%
### option: APIRequestContext.delete.form = %%-js-fetch-option-form-%%
* since: v1.17
### option: APIRequestContext.delete.form = %%-python-fetch-option-form-%%
* since: v1.17
### option: APIRequestContext.delete.form = %%-csharp-fetch-option-form-%%
@ -332,7 +335,10 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/
### option: APIRequestContext.fetch.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.16
### option: APIRequestContext.fetch.form = %%-js-python-fetch-option-form-%%
### option: APIRequestContext.fetch.form = %%-js-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.fetch.form = %%-python-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.fetch.form = %%-csharp-fetch-option-form-%%
@ -442,7 +448,10 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query
### option: APIRequestContext.get.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.26
### option: APIRequestContext.get.form = %%-js-python-fetch-option-form-%%
### option: APIRequestContext.get.form = %%-js-fetch-option-form-%%
* since: v1.26
### option: APIRequestContext.get.form = %%-python-fetch-option-form-%%
* since: v1.26
### option: APIRequestContext.get.form = %%-csharp-fetch-option-form-%%
@ -504,7 +513,10 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.head.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.26
### option: APIRequestContext.head.form = %%-js-python-fetch-option-form-%%
### option: APIRequestContext.head.form = %%-python-fetch-option-form-%%
* since: v1.26
### option: APIRequestContext.head.form = %%-js-fetch-option-form-%%
* since: v1.26
### option: APIRequestContext.head.form = %%-csharp-fetch-option-form-%%
@ -566,7 +578,10 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.patch.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.16
### option: APIRequestContext.patch.form = %%-js-python-fetch-option-form-%%
### option: APIRequestContext.patch.form = %%-js-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.patch.form = %%-python-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.patch.form = %%-csharp-fetch-option-form-%%
@ -749,7 +764,10 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar
### option: APIRequestContext.post.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.16
### option: APIRequestContext.post.form = %%-js-python-fetch-option-form-%%
### option: APIRequestContext.post.form = %%-js-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.post.form = %%-python-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.post.form = %%-csharp-fetch-option-form-%%
@ -811,7 +829,10 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.put.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.16
### option: APIRequestContext.put.form = %%-js-python-fetch-option-form-%%
### option: APIRequestContext.put.form = %%-python-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.put.form = %%-js-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.put.form = %%-csharp-fetch-option-form-%%

View file

@ -2333,6 +2333,11 @@ last redirect. If cannot go forward, returns `null`.
Navigate to the next page in history.
## async method: Page.forceGarbageCollection
* since: v1.47
Force the browser to perform garbage collection.
### option: Page.goForward.waitUntil = %%-navigation-wait-until-%%
* since: v1.8

View file

@ -11,7 +11,7 @@ const context = await browser.newContext();
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
await page.goto('https://playwright.dev');
await context.tracing.stop({ path: 'trace.zip' });
await context.tracing.stop({ path: 'trace.pwtrace.zip' });
```
```java
@ -23,7 +23,7 @@ context.tracing().start(new Tracing.StartOptions()
Page page = context.newPage();
page.navigate("https://playwright.dev");
context.tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("trace.zip")));
.setPath(Paths.get("trace.pwtrace.zip")));
```
```python async
@ -32,7 +32,7 @@ context = await browser.new_context()
await context.tracing.start(screenshots=True, snapshots=True)
page = await context.new_page()
await page.goto("https://playwright.dev")
await context.tracing.stop(path = "trace.zip")
await context.tracing.stop(path = "trace.pwtrace.zip")
```
```python sync
@ -41,7 +41,7 @@ context = browser.new_context()
context.tracing.start(screenshots=True, snapshots=True)
page = context.new_page()
page.goto("https://playwright.dev")
context.tracing.stop(path = "trace.zip")
context.tracing.stop(path = "trace.pwtrace.zip")
```
```csharp
@ -57,7 +57,7 @@ var page = await context.NewPageAsync();
await page.GotoAsync("https://playwright.dev");
await context.Tracing.StopAsync(new()
{
Path = "trace.zip"
Path = "trace.pwtrace.zip"
});
```
@ -72,7 +72,7 @@ Start tracing.
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
await page.goto('https://playwright.dev');
await context.tracing.stop({ path: 'trace.zip' });
await context.tracing.stop({ path: 'trace.pwtrace.zip' });
```
```java
@ -82,21 +82,21 @@ context.tracing().start(new Tracing.StartOptions()
Page page = context.newPage();
page.navigate("https://playwright.dev");
context.tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("trace.zip")));
.setPath(Paths.get("trace.pwtrace.zip")));
```
```python async
await context.tracing.start(screenshots=True, snapshots=True)
page = await context.new_page()
await page.goto("https://playwright.dev")
await context.tracing.stop(path = "trace.zip")
await context.tracing.stop(path = "trace.pwtrace.zip")
```
```python sync
context.tracing.start(screenshots=True, snapshots=True)
page = context.new_page()
page.goto("https://playwright.dev")
context.tracing.stop(path = "trace.zip")
context.tracing.stop(path = "trace.pwtrace.zip")
```
```csharp
@ -112,7 +112,7 @@ var page = await context.NewPageAsync();
await page.GotoAsync("https://playwright.dev");
await context.Tracing.StopAsync(new()
{
Path = "trace.zip"
Path = "trace.pwtrace.zip"
});
```
@ -177,12 +177,12 @@ await page.goto('https://playwright.dev');
await context.tracing.startChunk();
await page.getByText('Get Started').click();
// Everything between startChunk and stopChunk will be recorded in the trace.
await context.tracing.stopChunk({ path: 'trace1.zip' });
await context.tracing.stopChunk({ path: 'trace1.pwtrace.zip' });
await context.tracing.startChunk();
await page.goto('http://example.com');
// Save a second trace file with different actions.
await context.tracing.stopChunk({ path: 'trace2.zip' });
await context.tracing.stopChunk({ path: 'trace2.pwtrace.zip' });
```
```java
@ -196,13 +196,13 @@ context.tracing().startChunk();
page.getByText("Get Started").click();
// Everything between startChunk and stopChunk will be recorded in the trace.
context.tracing().stopChunk(new Tracing.StopChunkOptions()
.setPath(Paths.get("trace1.zip")));
.setPath(Paths.get("trace1.pwtrace.zip")));
context.tracing().startChunk();
page.navigate("http://example.com");
// Save a second trace file with different actions.
context.tracing().stopChunk(new Tracing.StopChunkOptions()
.setPath(Paths.get("trace2.zip")));
.setPath(Paths.get("trace2.pwtrace.zip")));
```
```python async
@ -213,12 +213,12 @@ await page.goto("https://playwright.dev")
await context.tracing.start_chunk()
await page.get_by_text("Get Started").click()
# Everything between start_chunk and stop_chunk will be recorded in the trace.
await context.tracing.stop_chunk(path = "trace1.zip")
await context.tracing.stop_chunk(path = "trace1.pwtrace.zip")
await context.tracing.start_chunk()
await page.goto("http://example.com")
# Save a second trace file with different actions.
await context.tracing.stop_chunk(path = "trace2.zip")
await context.tracing.stop_chunk(path = "trace2.pwtrace.zip")
```
```python sync
@ -229,12 +229,12 @@ page.goto("https://playwright.dev")
context.tracing.start_chunk()
page.get_by_text("Get Started").click()
# Everything between start_chunk and stop_chunk will be recorded in the trace.
context.tracing.stop_chunk(path = "trace1.zip")
context.tracing.stop_chunk(path = "trace1.pwtrace.zip")
context.tracing.start_chunk()
page.goto("http://example.com")
# Save a second trace file with different actions.
context.tracing.stop_chunk(path = "trace2.zip")
context.tracing.stop_chunk(path = "trace2.pwtrace.zip")
```
```csharp
@ -254,7 +254,7 @@ await page.GetByText("Get Started").ClickAsync();
// Everything between StartChunkAsync and StopChunkAsync will be recorded in the trace.
await context.Tracing.StopChunkAsync(new()
{
Path = "trace1.zip"
Path = "trace1.pwtrace.zip"
});
await context.Tracing.StartChunkAsync();
@ -262,7 +262,7 @@ await page.GotoAsync("http://example.com");
// Save a second trace file with different actions.
await context.Tracing.StopChunkAsync(new()
{
Path = "trace2.zip"
Path = "trace2.pwtrace.zip"
});
```

View file

@ -405,8 +405,16 @@ Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to d
Whether to throw on response codes other than 2xx and 3xx. By default response object is returned
for all status codes.
## js-python-fetch-option-form
* langs: js, python
## js-fetch-option-form
* langs: js
- `form` <[Object]<[string], [string]|[float]|[boolean]>|[FormData]>
Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as
this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded`
unless explicitly provided.
## python-fetch-option-form
* langs: python
- `form` <[Object]<[string], [string]|[float]|[boolean]>>
Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as

View file

@ -209,6 +209,7 @@ jobs:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v%%VERSION%%-jammy
options: --user 1001
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -218,8 +219,6 @@ jobs:
run: npm ci
- name: Run your tests
run: npx playwright test
env:
HOME: /root
```
```yml python title=".github/workflows/playwright.yml"
@ -235,6 +234,7 @@ jobs:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright/python:v%%VERSION%%-jammy
options: --user 1001
steps:
- uses: actions/checkout@v4
- name: Set up Python
@ -248,8 +248,6 @@ jobs:
pip install -e .
- name: Run your tests
run: pytest
env:
HOME: /root
```
```yml java title=".github/workflows/playwright.yml"
@ -265,6 +263,7 @@ jobs:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright/java:v%%VERSION%%-jammy
options: --user 1001
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
@ -275,8 +274,6 @@ jobs:
run: mvn -B install -D skipTests --no-transfer-progress
- name: Run tests
run: mvn test
env:
HOME: /root
```
```yml csharp title=".github/workflows/playwright.yml"
@ -292,6 +289,7 @@ jobs:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright/dotnet:v%%VERSION%%-jammy
options: --user 1001
steps:
- uses: actions/checkout@v4
- name: Setup dotnet
@ -301,8 +299,6 @@ jobs:
- run: dotnet build
- name: Run your tests
run: dotnet test
env:
HOME: /root
```
#### On deployment

View file

@ -238,12 +238,12 @@ async function globalSetup(config: FullConfig) {
await page.getByText('Sign in').click();
await context.storageState({ path: storageState as string });
await context.tracing.stop({
path: './test-results/setup-trace.zip',
path: './test-results/setup-trace.pwtrace.zip',
});
await browser.close();
} catch (error) {
await context.tracing.stop({
path: './test-results/failed-setup-trace.zip',
path: './test-results/failed-setup-trace.pwtrace.zip',
});
await browser.close();
throw error;

View file

@ -25,7 +25,7 @@ Options for tracing are:
- `off`: Do not record trace. (default)
- `retain-on-failure`: Record trace for each test, but remove all traces from successful test runs.
This will record the trace and place it into the file named `trace.zip` in your `test-results` directory.
This will record the trace and place it into the file named `trace.pwtrace.zip` in your `test-results` directory.
<details>
<summary>If you are not using Pytest, click here to learn how to record traces.</summary>
@ -41,7 +41,7 @@ page = await context.new_page()
await page.goto("https://playwright.dev")
# Stop tracing and export it into a zip archive.
await context.tracing.stop(path = "trace.zip")
await context.tracing.stop(path = "trace.pwtrace.zip")
```
```python sync
@ -55,7 +55,7 @@ page = context.new_page()
page.goto("https://playwright.dev")
# Stop tracing and export it into a zip archive.
context.tracing.stop(path = "trace.zip")
context.tracing.stop(path = "trace.pwtrace.zip")
```
</details>
@ -80,22 +80,22 @@ page.navigate("https://playwright.dev");
// Stop tracing and export it into a zip archive.
context.tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("trace.zip")));
.setPath(Paths.get("trace.pwtrace.zip")));
```
This will record the trace and place it into the file named `trace.zip`.
This will record the trace and place it into the file named `trace.pwtrace.zip`.
## Opening the trace
You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your trace's zip file is located. Once opened you can click on each action or use the timeline to see the state of the page before and after each action. You can also inspect the log, source and network during each step of the test. The trace viewer creates a DOM snapshot so you can fully interact with it, open devtools etc.
```bash java
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip"
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.pwtrace.zip"
```
```bash python
playwright show-trace trace.zip
playwright show-trace trace.pwtrace.zip
```
######

View file

@ -22,7 +22,7 @@ Playwright Trace Viewer is a GUI tool that lets you explore recorded Playwright
## Recording a Trace
By default the [playwright.config](./trace-viewer.md#recording-a-trace-on-ci) file will contain the configuration needed to create a `trace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry.
By default the [playwright.config](./trace-viewer.md#recording-a-trace-on-ci) file will contain the configuration needed to create a `trace.pwtrace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry.
```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';

View file

@ -132,7 +132,7 @@ npx playwright show-report
* langs: js
Traces should be run on continuous integration on the first retry of a failed test
by setting the `trace: 'on-first-retry'` option in the test configuration file. This will produce a `trace.zip` file for each test that was retried.
by setting the `trace: 'on-first-retry'` option in the test configuration file. This will produce a `trace.pwtrace.zip` file for each test that was retried.
```js tab=js-test title="playwright.config.ts"
import { defineConfig } from '@playwright/test';
@ -155,7 +155,7 @@ const page = await context.newPage();
await page.goto('https://playwright.dev');
// Stop tracing and export it into a zip archive.
await context.tracing.stop({ path: 'trace.zip' });
await context.tracing.stop({ path: 'trace.pwtrace.zip' });
```
Available options to record a trace:
@ -185,7 +185,7 @@ Options for tracing are:
- `off`: Do not record trace. (default)
- `retain-on-failure`: Record trace for each test, but remove all traces from successful test runs.
This will record the trace and place it into the file named `trace.zip` in your `test-results` directory.
This will record the trace and place it into the file named `trace.pwtrace.zip` in your `test-results` directory.
<details>
<summary>If you are not using Pytest, click here to learn how to record traces.</summary>
@ -201,7 +201,7 @@ page = await context.new_page()
await page.goto("https://playwright.dev")
# Stop tracing and export it into a zip archive.
await context.tracing.stop(path = "trace.zip")
await context.tracing.stop(path = "trace.pwtrace.zip")
```
```python sync
@ -215,7 +215,7 @@ page = context.new_page()
page.goto("https://playwright.dev")
# Stop tracing and export it into a zip archive.
context.tracing.stop(path = "trace.zip")
context.tracing.stop(path = "trace.pwtrace.zip")
```
</details>
@ -240,10 +240,10 @@ page.navigate("https://playwright.dev");
// Stop tracing and export it into a zip archive.
context.tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("trace.zip")));
.setPath(Paths.get("trace.pwtracezip")));
```
This will record the trace and place it into the file named `trace.zip`.
This will record the trace and place it into the file named `trace.pwtrace.zip`.
## Recording a trace
* langs: csharp
@ -466,22 +466,22 @@ public class ExampleTest : PageTest
## Opening the trace
You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located.
You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.pwtrace.zip` file is located.
```bash js
npx playwright show-trace path/to/trace.zip
npx playwright show-trace path/to/trace.pwtrace.zip
```
```bash java
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip"
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.pwtrace.zip"
```
```bash python
playwright show-trace trace.zip
playwright show-trace trace.pwtrace.zip
```
```bash csharp
pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip
pwsh bin/Debug/netX/playwright.ps1 show-trace trace.pwtrace.zip
```
## Using [trace.playwright.dev](https://trace.playwright.dev)
@ -496,19 +496,19 @@ pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip
You can open remote traces using its URL. They could be generated on a CI run which makes it easy to view the remote trace without having to manually download the file.
```bash js
npx playwright show-trace https://example.com/trace.zip
npx playwright show-trace https://example.com/trace.pwtrace.zip
```
```bash java
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip"
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.pwtrace.zip"
```
```bash python
playwright show-trace https://example.com/trace.zip
playwright show-trace https://example.com/trace.pwtrace.zip
```
```bash csharp
pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip
pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.pwtrace.zip
```

View file

@ -15,19 +15,19 @@
},
{
"name": "firefox",
"revision": "1463",
"revision": "1464",
"installByDefault": true,
"browserVersion": "130.0"
},
{
"name": "firefox-beta",
"revision": "1463",
"revision": "1464",
"installByDefault": false,
"browserVersion": "131.0b2"
},
{
"name": "webkit",
"revision": "2073",
"revision": "2075",
"installByDefault": true,
"revisionOverrides": {
"mac10.14": "1446",

View file

@ -318,7 +318,7 @@ program
}).addHelpText('afterAll', `
Examples:
$ show-trace https://example.com/trace.zip`);
$ show-trace https://example.com/trace.pwtrace.zip`);
type Options = {
browser: string;

View file

@ -36,8 +36,8 @@ export type FetchOptions = {
method?: string,
headers?: Headers,
data?: string | Buffer | Serializable,
form?: { [key: string]: string|number|boolean; };
multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; };
form?: { [key: string]: string|number|boolean; } | FormData;
multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; } | FormData;
timeout?: number,
failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean,
@ -202,7 +202,16 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
throw new Error(`Unexpected 'data' type`);
}
} else if (options.form) {
formData = objectToArray(options.form);
if (globalThis.FormData && options.form instanceof FormData) {
formData = [];
for (const [name, value] of options.form.entries()) {
if (typeof value !== 'string')
throw new Error(`Expected string for options.form["${name}"], found File. Please use options.multipart instead.`);
formData.push({ name, value });
}
} else {
formData = objectToArray(options.form);
}
} else if (options.multipart) {
multipartData = [];
if (globalThis.FormData && options.multipart instanceof FormData) {

View file

@ -468,6 +468,10 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return Response.fromNullable((await this._channel.goForward({ ...options, waitUntil })).response);
}
async forceGarbageCollection() {
await this._channel.forceGarbageCollection();
}
async emulateMedia(options: { media?: 'screen' | 'print' | null, colorScheme?: 'dark' | 'light' | 'no-preference' | null, reducedMotion?: 'reduce' | 'no-preference' | null, forcedColors?: 'active' | 'none' | null } = {}) {
await this._channel.emulateMedia({
media: options.media === null ? 'no-override' : options.media,

View file

@ -1122,6 +1122,8 @@ scheme.PageGoForwardParams = tObject({
scheme.PageGoForwardResult = tObject({
response: tOptional(tChannel(['Response'])),
});
scheme.PageForceGarbageCollectionParams = tOptional(tObject({}));
scheme.PageForceGarbageCollectionResult = tOptional(tObject({}));
scheme.PageRegisterLocatorHandlerParams = tObject({
selector: tString,
noWaitAfter: tOptional(tBoolean),

View file

@ -29,6 +29,7 @@ import { validateBrowserContextOptions } from '../browserContext';
import { ProgressController } from '../progress';
import { CRBrowser } from '../chromium/crBrowser';
import { helper } from '../helper';
import type * as types from '../types';
import { PipeTransport } from '../../protocol/transport';
import { RecentLogsCollector } from '../../utils/debugLogger';
import { gracefullyCloseSet } from '../../utils/processLauncher';
@ -309,7 +310,7 @@ export class AndroidDevice extends SdkObject {
return await this._connectToBrowser(socketName);
}
private async _connectToBrowser(socketName: string, options: channels.BrowserNewContextParams = {}): Promise<BrowserContext> {
private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
const socket = await this._waitForLocalAbstract(socketName);
const androidBrowser = new AndroidBrowser(this, socket);
await androidBrowser._init();

View file

@ -111,7 +111,7 @@ export class BidiBrowser extends Browser {
this._didClose();
}
async doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext> {
async doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
const { userContext } = await this._browserSession.send('browser.createUserContext', {});
const context = new BidiBrowserContext(this, userContext, options);
await context._initialize();
@ -190,7 +190,7 @@ export class BidiBrowser extends Browser {
export class BidiBrowserContext extends BrowserContext {
declare readonly _browser: BidiBrowser;
constructor(browser: BidiBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) {
constructor(browser: BidiBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) {
super(browser, options, browserContextId);
this._authenticateProxyViaHeader();
}

View file

@ -91,7 +91,7 @@ export class BidiChromium extends BrowserType {
}
private _innerDefaultArgs(options: types.LaunchOptions): string[] {
const { args = [], proxy } = options;
const { args = [] } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg)
throw this._createUserDataDirArgMisuseError('--user-data-dir');
@ -125,6 +125,7 @@ export class BidiChromium extends BrowserType {
}
if (options.chromiumSandbox !== true)
chromeArguments.push('--no-sandbox');
const proxy = options.proxyOverride || options.proxy;
if (proxy) {
const proxyURL = new URL(proxy.server);
const isSocks = proxyURL.protocol === 'socks5:';

View file

@ -22,6 +22,7 @@ import type * as frames from '../frames';
import type * as types from '../types';
import * as bidi from './third_party/bidiProtocol';
import type { BidiSession } from './bidiConnection';
import { parseRawCookie } from '../cookieStore';
export class BidiNetworkManager {
@ -68,7 +69,7 @@ export class BidiNetworkManager {
if (redirectedFrom) {
this._session.sendMayFail('network.continueRequest', {
request: param.request.request,
headers: redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders,
...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}),
});
} else {
route = new BidiRouteImpl(this._session, param.request.request);
@ -245,7 +246,7 @@ class BidiRouteImpl implements network.RouteDelegate {
private _requestId: bidi.Network.Request;
private _session: BidiSession;
private _request!: network.Request;
_alreadyContinuedHeaders: bidi.Network.Header[] | undefined;
_alreadyContinuedHeaders: types.HeadersArray | undefined;
constructor(session: BidiSession, requestId: bidi.Network.Request) {
this._session = session;
@ -266,13 +267,12 @@ class BidiRouteImpl implements network.RouteDelegate {
return header;
});
}
this._alreadyContinuedHeaders = toBidiHeaders(headers);
this._alreadyContinuedHeaders = headers;
await this._session.sendMayFail('network.continueRequest', {
request: this._requestId,
url: overrides.url,
method: overrides.method,
// TODO: cookies!
headers: this._alreadyContinuedHeaders,
...toBidiRequestHeaders(this._alreadyContinuedHeaders),
body: overrides.postData ? { type: 'base64', value: Buffer.from(overrides.postData).toString('base64') } : undefined,
});
}
@ -283,7 +283,7 @@ class BidiRouteImpl implements network.RouteDelegate {
request: this._requestId,
statusCode: response.status,
reasonPhrase: network.statusText(response.status),
headers: toBidiHeaders(response.headers),
...toBidiResponseHeaders(response.headers),
body: { type: 'base64', value: base64body },
});
}
@ -302,6 +302,27 @@ function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray
return result;
}
function toBidiRequestHeaders(allHeaders: types.HeadersArray): { cookies: bidi.Network.CookieHeader[], headers: bidi.Network.Header[] } {
const bidiHeaders = toBidiHeaders(allHeaders);
const cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie');
const headers = bidiHeaders.filter(h => h.name.toLowerCase() !== 'cookie');
return { cookies, headers };
}
function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } {
const setCookieHeaders = headers.filter(h => h.name.toLowerCase() === 'set-cookie');
const otherHeaders = headers.filter(h => h.name.toLowerCase() !== 'set-cookie');
const rawCookies = setCookieHeaders.map(h => parseRawCookie(h.value));
const cookies: bidi.Network.SetCookieHeader[] = rawCookies.filter(Boolean).map(c => {
return {
...c!,
value: { type: 'string', value: c!.value },
sameSite: toBidiSameSite(c!.sameSite),
};
});
return { cookies, headers: toBidiHeaders(otherHeaders) };
}
function toBidiHeaders(headers: types.HeadersArray): bidi.Network.Header[] {
return headers.map(({ name, value }) => ({ name, value: { type: 'string', value } }));
}
@ -314,3 +335,13 @@ export function bidiBytesValueToString(value: bidi.Network.BytesValue): string {
return 'unknown value type: ' + (value as any).type;
}
function toBidiSameSite(sameSite?: 'Strict' | 'Lax' | 'None'): bidi.Network.SameSite | undefined {
if (!sameSite)
return undefined;
if (sameSite === 'Strict')
return bidi.Network.SameSite.Strict;
if (sameSite === 'Lax')
return bidi.Network.SameSite.Lax;
return bidi.Network.SameSite.None;
}

View file

@ -323,6 +323,10 @@ export class BidiPage implements PageDelegate {
throw new Error('Method not implemented.');
}
async forceGarbageCollection(): Promise<void> {
throw new Error('Method not implemented.');
}
async addInitScript(initScript: InitScript): Promise<void> {
const { script } = await this._session.send('script.addPreloadScript', {
// TODO: remove function call from the source.

View file

@ -41,7 +41,7 @@ export type BrowserOptions = {
downloadsPath: string,
tracesDir: string,
headful?: boolean,
persistent?: channels.BrowserNewContextParams, // Undefined means no persistent context.
persistent?: types.BrowserContextOptions, // Undefined means no persistent context.
browserProcess: BrowserProcess,
customExecutablePath?: string;
proxy?: ProxySettings,
@ -74,15 +74,19 @@ export abstract class Browser extends SdkObject {
this.instrumentation.onBrowserOpen(this);
}
abstract doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext>;
abstract doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext>;
abstract contexts(): BrowserContext[];
abstract isConnected(): boolean;
abstract version(): string;
abstract userAgent(): string;
async newContext(metadata: CallMetadata, options: channels.BrowserNewContextParams): Promise<BrowserContext> {
async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise<BrowserContext> {
validateBrowserContextOptions(options, this.options);
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(options, this.options);
if (clientCertificatesProxy) {
options.proxyOverride = await clientCertificatesProxy.listen();
options.internalIgnoreHTTPSErrors = true;
}
let context;
try {
context = await this.doCreateNewContext(options);

View file

@ -68,7 +68,7 @@ export abstract class BrowserContext extends SdkObject {
readonly _timeoutSettings = new TimeoutSettings();
readonly _pageBindings = new Map<string, PageBinding>();
readonly _activeProgressControllers = new Set<ProgressController>();
readonly _options: channels.BrowserNewContextParams;
readonly _options: types.BrowserContextOptions;
_requestInterceptor?: network.RouteHandler;
private _isPersistentContext: boolean;
private _closedStatus: 'open' | 'closing' | 'closed' = 'open';
@ -93,7 +93,7 @@ export abstract class BrowserContext extends SdkObject {
readonly clock: Clock;
_clientCertificatesProxy: ClientCertificatesProxy | undefined;
constructor(browser: Browser, options: channels.BrowserNewContextParams, browserContextId: string | undefined) {
constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
super(browser, 'browser-context');
this.attribution.context = this;
this._browser = browser;
@ -659,19 +659,16 @@ export function assertBrowserContextIsNotOwned(context: BrowserContext) {
}
}
export async function createClientCertificatesProxyIfNeeded(options: channels.BrowserNewContextOptions, browserOptions?: BrowserOptions) {
export async function createClientCertificatesProxyIfNeeded(options: types.BrowserContextOptions, browserOptions?: BrowserOptions) {
if (!options.clientCertificates?.length)
return;
if ((options.proxy?.server && options.proxy?.server !== 'per-context') || (browserOptions?.proxy?.server && browserOptions?.proxy?.server !== 'http://per-context'))
throw new Error('Cannot specify both proxy and clientCertificates');
verifyClientCertificates(options.clientCertificates);
const clientCertificatesProxy = new ClientCertificatesProxy(options);
options.proxy = { server: await clientCertificatesProxy.listen() };
options.ignoreHTTPSErrors = true;
return clientCertificatesProxy;
return new ClientCertificatesProxy(options);
}
export function validateBrowserContextOptions(options: channels.BrowserNewContextParams, browserOptions: BrowserOptions) {
export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) {
if (options.noDefaultViewport && options.deviceScaleFactor !== undefined)
throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`);
if (options.noDefaultViewport && !!options.isMobile)
@ -720,7 +717,7 @@ export function verifyGeolocation(geolocation?: types.Geolocation) {
throw new Error(`geolocation.accuracy: precondition 0 <= ACCURACY failed.`);
}
export function verifyClientCertificates(clientCertificates?: channels.BrowserNewContextParams['clientCertificates']) {
export function verifyClientCertificates(clientCertificates?: types.BrowserContextOptions['clientCertificates']) {
if (!clientCertificates)
return;
for (const cert of clientCertificates) {

View file

@ -92,27 +92,26 @@ export abstract class BrowserType extends SdkObject {
return browser;
}
async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise<BrowserContext> {
options = this._validateLaunchOptions(options);
async launchPersistentContext(metadata: CallMetadata, userDataDir: string, persistentContextOptions: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise<BrowserContext> {
const launchOptions = this._validateLaunchOptions(persistentContextOptions);
if (this._useBidi)
options.useWebSocket = true;
launchOptions.useWebSocket = true;
const controller = new ProgressController(metadata, this);
const persistent: channels.BrowserNewContextParams = { ...options };
controller.setLogName('browser');
const browser = await controller.run(async progress => {
// Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors.
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistent);
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistentContextOptions);
if (clientCertificatesProxy)
options.proxy = persistent.proxy;
launchOptions.proxyOverride = await clientCertificatesProxy?.listen();
progress.cleanupWhenAborted(() => clientCertificatesProxy?.close());
const browser = await this._innerLaunchWithRetries(progress, options, persistent, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); });
const browser = await this._innerLaunchWithRetries(progress, launchOptions, persistentContextOptions, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); });
browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy;
return browser;
}, TimeoutSettings.launchTimeout(options));
}, TimeoutSettings.launchTimeout(launchOptions));
return browser._defaultContext!;
}
async _innerLaunchWithRetries(progress: Progress, options: types.LaunchOptions, persistent: channels.BrowserNewContextParams | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise<Browser> {
async _innerLaunchWithRetries(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise<Browser> {
try {
return await this._innerLaunch(progress, options, persistent, protocolLogger, userDataDir);
} catch (error) {
@ -126,7 +125,7 @@ export abstract class BrowserType extends SdkObject {
}
}
async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: channels.BrowserNewContextParams | undefined, protocolLogger: types.ProtocolLogger, maybeUserDataDir?: string): Promise<Browser> {
async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, maybeUserDataDir?: string): Promise<Browser> {
options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined;
const browserLogsCollector = new RecentLogsCollector();
const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir);
@ -289,7 +288,7 @@ export abstract class BrowserType extends SdkObject {
throw new Error('Connecting to SELENIUM_REMOTE_URL is only supported by Chromium');
}
private _validateLaunchOptions<Options extends types.LaunchOptions>(options: Options): Options {
private _validateLaunchOptions(options: types.LaunchOptions): types.LaunchOptions {
const { devtools = false } = options;
let { headless = !devtools, downloadsPath, proxy } = options;
if (debugMode())

View file

@ -31,7 +31,6 @@ import { CRDevTools } from './crDevTools';
import type { BrowserOptions, BrowserProcess } from '../browser';
import { Browser } from '../browser';
import type * as types from '../types';
import type * as channels from '@protocol/channels';
import type { HTTPRequestParams } from '../../utils/network';
import { fetchData } from '../../utils/network';
import { getUserAgent } from '../../utils/userAgent';
@ -98,7 +97,7 @@ export class Chromium extends BrowserType {
await cleanedUp;
};
const browserProcess: BrowserProcess = { close: doClose, kill: doClose };
const persistent: channels.BrowserNewContextParams = { noDefaultViewport: true };
const persistent: types.BrowserContextOptions = { noDefaultViewport: true };
const browserOptions: BrowserOptions = {
slowMo: options.slowMo,
name: 'chromium',
@ -287,7 +286,7 @@ export class Chromium extends BrowserType {
}
private _innerDefaultArgs(options: types.LaunchOptions): string[] {
const { args = [], proxy } = options;
const { args = [] } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg)
throw this._createUserDataDirArgMisuseError('--user-data-dir');
@ -321,6 +320,7 @@ export class Chromium extends BrowserType {
}
if (options.chromiumSandbox !== true)
chromeArguments.push('--no-sandbox');
const proxy = options.proxyOverride || options.proxy;
if (proxy) {
const proxyURL = new URL(proxy.server);
const isSocks = proxyURL.protocol === 'socks5:';

View file

@ -100,18 +100,19 @@ export class CRBrowser extends Browser {
this._session.on('Browser.downloadProgress', this._onDownloadProgress.bind(this));
}
async doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext> {
async doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
const proxy = options.proxyOverride || options.proxy;
let proxyBypassList = undefined;
if (options.proxy) {
if (proxy) {
if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK)
proxyBypassList = options.proxy.bypass;
proxyBypassList = proxy.bypass;
else
proxyBypassList = '<-loopback>' + (options.proxy.bypass ? `,${options.proxy.bypass}` : '');
proxyBypassList = '<-loopback>' + (proxy.bypass ? `,${proxy.bypass}` : '');
}
const { browserContextId } = await this._session.send('Target.createBrowserContext', {
disposeOnDetach: true,
proxyServer: options.proxy ? options.proxy.server : undefined,
proxyServer: proxy ? proxy.server : undefined,
proxyBypassList,
});
const context = new CRBrowserContext(this, browserContextId, options);
@ -340,7 +341,7 @@ export class CRBrowserContext extends BrowserContext {
declare readonly _browser: CRBrowser;
constructor(browser: CRBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) {
constructor(browser: CRBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) {
super(browser, options, browserContextId);
this._authenticateProxyViaCredentials();
}

View file

@ -247,6 +247,10 @@ export class CRPage implements PageDelegate {
return this._go(+1);
}
async forceGarbageCollection(): Promise<void> {
await this._mainFrameSession._client.send('HeapProfiler.collectGarbage');
}
async addInitScript(initScript: InitScript, world: types.World = 'main'): Promise<void> {
await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world));
}
@ -543,7 +547,7 @@ class FrameSession {
const options = this._crPage._browserContext._options;
if (options.bypassCSP)
promises.push(this._client.send('Page.setBypassCSP', { enabled: true }));
if (options.ignoreHTTPSErrors)
if (options.ignoreHTTPSErrors || options.internalIgnoreHTTPSErrors)
promises.push(this._client.send('Security.setIgnoreCertificateErrors', { ignore: true }));
if (this._isMainFrame())
promises.push(this._updateViewport());
@ -1213,7 +1217,7 @@ async function emulateTimezone(session: CRSession, timezoneId: string) {
const contextDelegateSymbol = Symbol('delegate');
// Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2
function calculateUserAgentMetadata(options: channels.BrowserNewContextParams) {
function calculateUserAgentMetadata(options: types.BrowserContextOptions) {
const ua = options.userAgent;
if (!ua)
return undefined;

View file

@ -15,6 +15,7 @@
*/
import type * as channels from '@protocol/channels';
import { kMaxCookieExpiresDateInSeconds } from './network';
class Cookie {
private _raw: channels.NetworkCookie;
@ -115,6 +116,97 @@ export class CookieStore {
}
}
type RawCookie = {
name: string,
value: string,
domain?: string,
path?: string,
expires?: number,
httpOnly?: boolean,
secure?: boolean,
sameSite?: 'Strict' | 'Lax' | 'None',
};
export function parseRawCookie(header: string): RawCookie | null {
const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => {
let key = '';
let value = '';
const separatorPos = p.indexOf('=');
if (separatorPos === -1) {
// If only a key is specified, the value is left undefined.
key = p.trim();
} else {
// Otherwise we assume that the key is the element before the first `=`
key = p.slice(0, separatorPos).trim();
// And the value is the rest of the string.
value = p.slice(separatorPos + 1).trim();
}
return [key, value];
});
if (!pairs.length)
return null;
const [name, value] = pairs[0];
const cookie: RawCookie = {
name,
value,
};
for (let i = 1; i < pairs.length; i++) {
const [name, value] = pairs[i];
switch (name.toLowerCase()) {
case 'expires':
const expiresMs = (+new Date(value));
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1
if (isFinite(expiresMs)) {
if (expiresMs <= 0)
cookie.expires = 0;
else
cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds);
}
break;
case 'max-age':
const maxAgeSec = parseInt(value, 10);
if (isFinite(maxAgeSec)) {
// From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2
// If delta-seconds is less than or equal to zero (0), let expiry-time
// be the earliest representable date and time.
if (maxAgeSec <= 0)
cookie.expires = 0;
else
cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds);
}
break;
case 'domain':
cookie.domain = value.toLocaleLowerCase() || '';
if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.'))
cookie.domain = '.' + cookie.domain;
break;
case 'path':
cookie.path = value || '';
break;
case 'secure':
cookie.secure = true;
break;
case 'httponly':
cookie.httpOnly = true;
break;
case 'samesite':
switch (value.toLowerCase()) {
case 'none':
cookie.sameSite = 'None';
break;
case 'lax':
cookie.sameSite = 'Lax';
break;
case 'strict':
cookie.sameSite = 'Strict';
break;
}
break;
}
}
return cookie;
}
export function domainMatches(value: string, domain: string): boolean {
if (value === domain)
return true;

View file

@ -137,6 +137,10 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
return { response: ResponseDispatcher.fromNullable(this.parentScope(), await this._page.goForward(metadata, params)) };
}
async forceGarbageCollection(params: channels.PageForceGarbageCollectionParams, metadata: CallMetadata): Promise<channels.PageForceGarbageCollectionResult> {
await this._page.forceGarbageCollection();
}
async registerLocatorHandler(params: channels.PageRegisterLocatorHandlerParams, metadata: CallMetadata): Promise<channels.PageRegisterLocatorHandlerResult> {
const uid = this._page.registerLocatorHandler(params.selector, params.noWaitAfter);
return { uid };

View file

@ -36,6 +36,7 @@ import type { BrowserWindow } from 'electron';
import type { Progress } from '../progress';
import { ProgressController } from '../progress';
import { helper } from '../helper';
import type * as types from '../types';
import { eventsHelper } from '../../utils/eventsHelper';
import type { BrowserOptions, BrowserProcess } from '../browser';
import type { Playwright } from '../playwright';
@ -265,7 +266,7 @@ export class Electron extends SdkObject {
close: gracefullyClose,
kill
};
const contextOptions: channels.BrowserNewContextParams = {
const contextOptions: types.BrowserContextOptions = {
...options,
noDefaultViewport: true,
};

View file

@ -28,7 +28,7 @@ import { getUserAgent } from '../utils/userAgent';
import { assert, createGuid, monotonicTime } from '../utils';
import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle';
import { BrowserContext, verifyClientCertificates } from './browserContext';
import { CookieStore, domainMatches } from './cookieStore';
import { CookieStore, domainMatches, parseRawCookie } from './cookieStore';
import { MultipartFormData } from './formData';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-eyeballs';
import type { CallMetadata } from './instrumentation';
@ -39,7 +39,6 @@ import { ProgressController } from './progress';
import { Tracing } from './trace/recorder/tracing';
import type * as types from './types';
import type { HeadersArray, ProxySettings } from './types';
import { kMaxCookieExpiresDateInSeconds } from './network';
import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor';
type FetchRequestOptions = {
@ -50,7 +49,7 @@ type FetchRequestOptions = {
timeoutSettings: TimeoutSettings;
ignoreHTTPSErrors?: boolean;
baseURL?: string;
clientCertificates?: channels.BrowserNewContextOptions['clientCertificates'];
clientCertificates?: types.BrowserContextOptions['clientCertificates'];
};
type HeadersObject = Readonly<{ [name: string]: string }>;
@ -640,27 +639,10 @@ function toHeadersArray(rawHeaders: string[]): types.HeadersArray {
const redirectStatus = [301, 302, 303, 307, 308];
function parseCookie(header: string): channels.NetworkCookie | null {
const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => {
let key = '';
let value = '';
const separatorPos = p.indexOf('=');
if (separatorPos === -1) {
// If only a key is specified, the value is left undefined.
key = p.trim();
} else {
// Otherwise we assume that the key is the element before the first `=`
key = p.slice(0, separatorPos).trim();
// And the value is the rest of the string.
value = p.slice(separatorPos + 1).trim();
}
return [key, value];
});
if (!pairs.length)
const raw = parseRawCookie(header);
if (!raw)
return null;
const [name, value] = pairs[0];
const cookie: channels.NetworkCookie = {
name,
value,
domain: '',
path: '',
expires: -1,
@ -668,62 +650,9 @@ function parseCookie(header: string): channels.NetworkCookie | null {
secure: false,
// From https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
// The cookie-sending behavior if SameSite is not specified is SameSite=Lax.
sameSite: 'Lax'
sameSite: 'Lax',
...raw
};
for (let i = 1; i < pairs.length; i++) {
const [name, value] = pairs[i];
switch (name.toLowerCase()) {
case 'expires':
const expiresMs = (+new Date(value));
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1
if (isFinite(expiresMs)) {
if (expiresMs <= 0)
cookie.expires = 0;
else
cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds);
}
break;
case 'max-age':
const maxAgeSec = parseInt(value, 10);
if (isFinite(maxAgeSec)) {
// From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2
// If delta-seconds is less than or equal to zero (0), let expiry-time
// be the earliest representable date and time.
if (maxAgeSec <= 0)
cookie.expires = 0;
else
cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds);
}
break;
case 'domain':
cookie.domain = value.toLocaleLowerCase() || '';
if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.'))
cookie.domain = '.' + cookie.domain;
break;
case 'path':
cookie.path = value || '';
break;
case 'secure':
cookie.secure = true;
break;
case 'httponly':
cookie.httpOnly = true;
break;
case 'samesite':
switch (value.toLowerCase()) {
case 'none':
cookie.sameSite = 'None';
break;
case 'lax':
cookie.sameSite = 'Lax';
break;
case 'strict':
cookie.sameSite = 'Strict';
break;
}
break;
}
}
return cookie;
}

View file

@ -58,8 +58,9 @@ export class FFBrowser extends Browser {
browser._defaultContext = new FFBrowserContext(browser, undefined, options.persistent);
promises.push((browser._defaultContext as FFBrowserContext)._initialize());
}
if (options.proxy)
promises.push(browser.session.send('Browser.setBrowserProxy', toJugglerProxyOptions(options.proxy)));
const proxy = options.originalLaunchOptions.proxyOverride || options.proxy;
if (proxy)
promises.push(browser.session.send('Browser.setBrowserProxy', toJugglerProxyOptions(proxy)));
await Promise.all(promises);
return browser;
}
@ -88,7 +89,7 @@ export class FFBrowser extends Browser {
return !this._connection._closed;
}
async doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext> {
async doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
if (options.isMobile)
throw new Error('options.isMobile is not supported in Firefox');
const { browserContextId } = await this.session.send('Browser.createBrowserContext', { removeOnDetach: true });
@ -172,7 +173,7 @@ export class FFBrowser extends Browser {
export class FFBrowserContext extends BrowserContext {
declare readonly _browser: FFBrowser;
constructor(browser: FFBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) {
constructor(browser: FFBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) {
super(browser, options, browserContextId);
}
@ -205,7 +206,7 @@ export class FFBrowserContext extends BrowserContext {
promises.push(this._browser.session.send('Browser.setUserAgentOverride', { browserContextId, userAgent: this._options.userAgent }));
if (this._options.bypassCSP)
promises.push(this._browser.session.send('Browser.setBypassCSP', { browserContextId, bypassCSP: true }));
if (this._options.ignoreHTTPSErrors)
if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors)
promises.push(this._browser.session.send('Browser.setIgnoreHTTPSErrors', { browserContextId, ignoreHTTPSErrors: true }));
if (this._options.javaScriptEnabled === false)
promises.push(this._browser.session.send('Browser.setJavaScriptDisabled', { browserContextId, javaScriptDisabled: true }));
@ -251,10 +252,11 @@ export class FFBrowserContext extends BrowserContext {
});
}));
}
if (this._options.proxy) {
const proxy = this._options.proxyOverride || this._options.proxy;
if (proxy) {
promises.push(this._browser.session.send('Browser.setContextProxy', {
browserContextId: this._browserContextId,
...toJugglerProxyOptions(this._options.proxy)
...toJugglerProxyOptions(proxy)
}));
}

View file

@ -400,6 +400,10 @@ export class FFPage implements PageDelegate {
return success;
}
async forceGarbageCollection(): Promise<void> {
await this._session.send('Heap.collectGarbage');
}
async addInitScript(initScript: InitScript, worldName?: string): Promise<void> {
this._initScripts.push({ initScript, worldName });
await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) });

View file

@ -315,6 +315,11 @@ export module Protocol {
};
export type cancelDownloadReturnValue = void;
}
export module Heap {
export type collectGarbageParameters = {
};
export type collectGarbageReturnValue = void;
}
export module Page {
export type DOMPoint = {
x: number;
@ -1124,6 +1129,7 @@ export module Protocol {
"Browser.setForcedColors": Browser.setForcedColorsParameters;
"Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsParameters;
"Browser.cancelDownload": Browser.cancelDownloadParameters;
"Heap.collectGarbage": Heap.collectGarbageParameters;
"Page.close": Page.closeParameters;
"Page.setFileInputFiles": Page.setFileInputFilesParameters;
"Page.addBinding": Page.addBindingParameters;
@ -1204,6 +1210,7 @@ export module Protocol {
"Browser.setForcedColors": Browser.setForcedColorsReturnValue;
"Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsReturnValue;
"Browser.cancelDownload": Browser.cancelDownloadReturnValue;
"Heap.collectGarbage": Heap.collectGarbageReturnValue;
"Page.close": Page.closeReturnValue;
"Page.setFileInputFiles": Page.setFileInputFilesReturnValue;
"Page.addBinding": Page.addBindingReturnValue;

View file

@ -54,6 +54,7 @@ export interface PageDelegate {
reload(): Promise<void>;
goBack(): Promise<boolean>;
goForward(): Promise<boolean>;
forceGarbageCollection(): Promise<void>;
addInitScript(initScript: InitScript): Promise<void>;
removeNonInternalInitScripts(): Promise<void>;
closePage(runBeforeUnload: boolean): Promise<void>;
@ -430,6 +431,10 @@ export class Page extends SdkObject {
}), this._timeoutSettings.navigationTimeout(options));
}
forceGarbageCollection(): Promise<void> {
return this._delegate.forceGarbageCollection();
}
registerLocatorHandler(selector: string, noWaitAfter: boolean | undefined) {
const uid = ++this._lastLocatorHandlerUid;
this._locatorHandlers.set(uid, { selector, noWaitAfter });

View file

@ -23,7 +23,7 @@ import { createSocket, createTLSSocket } from '../utils/happy-eyeballs';
import { escapeHTML, generateSelfSignedCertificate, ManualPromise, rewriteErrorMessage } from '../utils';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy';
import { SocksProxy } from '../common/socksProxy';
import type * as channels from '@protocol/channels';
import type * as types from './types';
import { debugLogger } from '../utils/debugLogger';
let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined;
@ -235,7 +235,7 @@ export class ClientCertificatesProxy {
alpnCache: ALPNCache;
constructor(
contextOptions: Pick<channels.BrowserNewContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'>
contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'>
) {
this.alpnCache = new ALPNCache();
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
@ -261,9 +261,9 @@ export class ClientCertificatesProxy {
loadDummyServerCertsIfNeeded();
}
_initSecureContexts(clientCertificates: channels.BrowserNewContextOptions['clientCertificates']) {
_initSecureContexts(clientCertificates: types.BrowserContextOptions['clientCertificates']) {
// Step 1. Group certificates by origin.
const origin2certs = new Map<string, channels.BrowserNewContextOptions['clientCertificates']>();
const origin2certs = new Map<string, types.BrowserContextOptions['clientCertificates']>();
for (const cert of clientCertificates || []) {
const origin = normalizeOrigin(cert.origin);
const certs = origin2certs.get(origin) || [];
@ -282,9 +282,9 @@ export class ClientCertificatesProxy {
}
}
public async listen(): Promise<string> {
public async listen() {
const port = await this._socksProxy.listen(0, '127.0.0.1');
return `socks5://127.0.0.1:${port}`;
return { server: `socks5://127.0.0.1:${port}` };
}
public async close() {
@ -301,7 +301,7 @@ function normalizeOrigin(origin: string): string {
}
function convertClientCertificatesToTLSOptions(
clientCertificates: channels.BrowserNewContextOptions['clientCertificates']
clientCertificates: types.BrowserContextOptions['clientCertificates']
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
if (!clientCertificates || !clientCertificates.length)
return;
@ -322,7 +322,7 @@ function convertClientCertificatesToTLSOptions(
}
export function getMatchingTLSOptionsForOrigin(
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'],
clientCertificates: types.BrowserContextOptions['clientCertificates'],
origin: string
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
const matchingCerts = clientCertificates?.filter(c =>

View file

@ -310,7 +310,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
this._fs.copyFile(this._state.networkFile, newNetworkFile);
const zipFileName = this._state.traceFile + '.zip';
const zipFileName = this._state.traceFile + '.pwtrace.zip';
if (params.mode === 'archive')
this._fs.zip(entries, zipFileName);

View file

@ -150,7 +150,15 @@ export type NormalizedContinueOverrides = {
export type EmulatedSize = { viewport: Size, screen: Size };
export type LaunchOptions = channels.BrowserTypeLaunchOptions & { useWebSocket?: boolean };
export type LaunchOptions = channels.BrowserTypeLaunchOptions & {
useWebSocket?: boolean,
proxyOverride?: ProxySettings,
};
export type BrowserContextOptions = channels.BrowserNewContextOptions & {
proxyOverride?: ProxySettings;
internalIgnoreHTTPSErrors?: boolean;
};
export type ProtocolLogger = (direction: 'send' | 'receive', message: object) => void;

View file

@ -53,7 +53,7 @@ export class WebKit extends BrowserType {
}
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
const { args = [], proxy, headless } = options;
const { args = [], headless } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg)
throw this._createUserDataDirArgMisuseError('--user-data-dir');
@ -68,6 +68,7 @@ export class WebKit extends BrowserType {
webkitArguments.push(`--user-data-dir=${userDataDir}`);
else
webkitArguments.push(`--no-startup-window`);
const proxy = options.proxyOverride || options.proxy;
if (proxy) {
if (process.platform === 'darwin') {
webkitArguments.push(`--proxy=${proxy.server}`);

View file

@ -81,12 +81,13 @@ export class WKBrowser extends Browser {
this._didClose();
}
async doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext> {
const createOptions = options.proxy ? {
// Enable socks5 hostname resolution on Windows. Workaround can be removed once fixed upstream.
async doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
const proxy = options.proxyOverride || options.proxy;
const createOptions = proxy ? {
// Enable socks5 hostname resolution on Windows.
// See https://github.com/microsoft/playwright/issues/20451
proxyServer: process.platform === 'win32' ? options.proxy.server.replace(/^socks5:\/\//, 'socks5h://') : options.proxy.server,
proxyBypassList: options.proxy.bypass
proxyServer: process.platform === 'win32' ? proxy.server.replace(/^socks5:\/\//, 'socks5h://') : proxy.server,
proxyBypassList: proxy.bypass
} : undefined;
const { browserContextId } = await this._browserSession.send('Playwright.createContext', createOptions);
options.userAgent = options.userAgent || DEFAULT_USER_AGENT;
@ -206,7 +207,7 @@ export class WKBrowser extends Browser {
export class WKBrowserContext extends BrowserContext {
declare readonly _browser: WKBrowser;
constructor(browser: WKBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) {
constructor(browser: WKBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) {
super(browser, options, browserContextId);
this._validateEmulatedViewport(options.viewport);
this._authenticateProxyViaHeader();
@ -221,7 +222,7 @@ export class WKBrowserContext extends BrowserContext {
downloadPath: this._browser.options.downloadsPath,
browserContextId
}));
if (this._options.ignoreHTTPSErrors)
if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors)
promises.push(this._browser._browserSession.send('Playwright.setIgnoreCertificateErrors', { browserContextId, ignore: true }));
if (this._options.locale)
promises.push(this._browser._browserSession.send('Playwright.setLanguages', { browserContextId, languages: [this._options.locale] }));

View file

@ -768,6 +768,10 @@ export class WKPage implements PageDelegate {
});
}
async forceGarbageCollection(): Promise<void> {
await this._session.send('Heap.gc');
}
async addInitScript(initScript: InitScript): Promise<void> {
await this._updateBootstrapScript();
}

View file

@ -2554,6 +2554,11 @@ export interface Page {
timeout?: number;
}): Promise<void>;
/**
* Force the browser to perform garbage collection.
*/
forceGarbageCollection(): Promise<void>;
/**
* Returns frame matching the specified criteria. Either `name` or `url` must be specified.
*
@ -16559,7 +16564,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided.
*/
form?: { [key: string]: string|number|boolean; };
form?: { [key: string]: string|number|boolean; }|FormData;
/**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -16689,7 +16694,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided.
*/
form?: { [key: string]: string|number|boolean; };
form?: { [key: string]: string|number|boolean; }|FormData;
/**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -16807,7 +16812,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided.
*/
form?: { [key: string]: string|number|boolean; };
form?: { [key: string]: string|number|boolean; }|FormData;
/**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -16893,7 +16898,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided.
*/
form?: { [key: string]: string|number|boolean; };
form?: { [key: string]: string|number|boolean; }|FormData;
/**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -16979,7 +16984,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided.
*/
form?: { [key: string]: string|number|boolean; };
form?: { [key: string]: string|number|boolean; }|FormData;
/**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -17107,7 +17112,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided.
*/
form?: { [key: string]: string|number|boolean; };
form?: { [key: string]: string|number|boolean; }|FormData;
/**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -17193,7 +17198,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided.
*/
form?: { [key: string]: string|number|boolean; };
form?: { [key: string]: string|number|boolean; }|FormData;
/**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -19925,7 +19930,7 @@ export interface Touchscreen {
* await context.tracing.start({ screenshots: true, snapshots: true });
* const page = await context.newPage();
* await page.goto('https://playwright.dev');
* await context.tracing.stop({ path: 'trace.zip' });
* await context.tracing.stop({ path: 'trace.pwtrace.zip' });
* ```
*
*/
@ -19939,7 +19944,7 @@ export interface Tracing {
* await context.tracing.start({ screenshots: true, snapshots: true });
* const page = await context.newPage();
* await page.goto('https://playwright.dev');
* await context.tracing.stop({ path: 'trace.zip' });
* await context.tracing.stop({ path: 'trace.pwtrace.zip' });
* ```
*
* @param options
@ -19994,12 +19999,12 @@ export interface Tracing {
* await context.tracing.startChunk();
* await page.getByText('Get Started').click();
* // Everything between startChunk and stopChunk will be recorded in the trace.
* await context.tracing.stopChunk({ path: 'trace1.zip' });
* await context.tracing.stopChunk({ path: 'trace1.pwtrace.zip' });
*
* await context.tracing.startChunk();
* await page.goto('http://example.com');
* // Save a second trace file with different actions.
* await context.tracing.stopChunk({ path: 'trace2.zip' });
* await context.tracing.stopChunk({ path: 'trace2.pwtrace.zip' });
* ```
*
* @param options

View file

@ -28,6 +28,7 @@ export interface TestServerInterface {
closeOnDisconnect?: boolean,
interceptStdio?: boolean,
watchTestDirs?: boolean,
populateDependenciesOnList?: boolean,
}): Promise<void>;
ping(params: {}): Promise<void>;

View file

@ -121,10 +121,10 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
const [actual, messageOrOptions] = argumentsList;
const message = isString(messageOrOptions) ? messageOrOptions : messageOrOptions?.message || info.message;
const newInfo = { ...info, message };
if (newInfo.isPoll) {
if (newInfo.poll) {
if (typeof actual !== 'function')
throw new Error('`expect.poll()` accepts only function as a first argument');
newInfo.generator = actual as any;
newInfo.poll.generator = actual as any;
}
return createMatchers(actual, newInfo, prefix);
},
@ -189,10 +189,10 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
if ('soft' in configuration)
newInfo.isSoft = configuration.soft;
if ('_poll' in configuration) {
newInfo.isPoll = !!configuration._poll;
newInfo.poll = configuration._poll ? { ...info.poll, generator: () => {} } : undefined;
if (typeof configuration._poll === 'object') {
newInfo.pollTimeout = configuration._poll.timeout;
newInfo.pollIntervals = configuration._poll.intervals;
newInfo.poll!.timeout = configuration._poll.timeout ?? newInfo.poll!.timeout;
newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals;
}
}
return createExpect(newInfo, prefix, customMatchers);
@ -249,11 +249,12 @@ type ExpectMetaInfo = {
message?: string;
isNot?: boolean;
isSoft?: boolean;
isPoll?: boolean;
poll?: {
timeout?: number;
intervals?: number[];
generator: Generator;
};
timeout?: number;
pollTimeout?: number;
pollIntervals?: number[];
generator?: Generator;
};
class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
@ -287,10 +288,10 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
this._info.isNot = !this._info.isNot;
return new Proxy(matcher, this);
}
if (this._info.isPoll) {
if (this._info.poll) {
if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects')
throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`);
matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args);
matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, this._info, this._prefix, ...args);
}
return (...args: any[]) => {
const testInfo = currentTestInfo();
@ -302,7 +303,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
const customMessage = this._info.message || '';
const argsSuffix = computeArgsSuffix(matcherName, args);
const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`;
const defaultTitle = `expect${this._info.poll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`;
const title = customMessage || defaultTitle;
// This looks like it is unnecessary, but it isn't - we need to filter
@ -336,7 +337,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
const callback = () => matcher.call(target, ...args);
// toPass and poll matchers can contain other steps, expects and API calls,
// so they behave like a retriable step.
const result = (matcherName === 'toPass' || this._info.isPoll) ?
const result = (matcherName === 'toPass' || this._info.poll) ?
zones.run('stepZone', step, callback) :
zones.run<ExpectZone, any>('expectZone', { title, stepId: step.stepId }, callback);
if (result instanceof Promise)
@ -350,25 +351,32 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
}
}
async function pollMatcher(qualifiedMatcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) {
async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) {
const testInfo = currentTestInfo();
const poll = info.poll!;
const timeout = poll.timeout ?? currentExpectTimeout();
const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout);
const result = await pollAgainstDeadline<Error|undefined>(async () => {
if (testInfo && currentTestInfo() !== testInfo)
return { continuePolling: false, result: undefined };
const value = await generator();
let expectInstance = expectLibrary(value) as any;
if (isNot)
expectInstance = expectInstance.not;
const innerInfo: ExpectMetaInfo = {
...info,
isSoft: false, // soft is outside of poll, not inside
poll: undefined,
};
const value = await poll.generator();
try {
expectInstance[qualifiedMatcherName].call(expectInstance, ...args);
let matchers = createMatchers(value, innerInfo, prefix);
if (info.isNot)
matchers = matchers.not;
matchers[qualifiedMatcherName](...args);
return { continuePolling: false, result: undefined };
} catch (error) {
return { continuePolling: true, result: error };
}
}, deadline, pollIntervals ?? [100, 250, 500, 1000]);
}, deadline, poll.intervals ?? [100, 250, 500, 1000]);
if (result.timedOut) {
const message = result.result ? [

View file

@ -133,7 +133,7 @@ Examples:
}
function addMergeReportsCommand(program: Command) {
const command = program.command('merge-reports [dir]', { hidden: true });
const command = program.command('merge-reports [dir]');
command.description('merge multiple blob reports (for sharded tests) into a single report');
command.action(async (dir, options) => {
try {

View file

@ -79,6 +79,7 @@ export class TestServerDispatcher implements TestServerInterface {
private _serializer = require.resolve('./uiModeReporter');
private _watchTestDirs = false;
private _closeOnDisconnect = false;
private _populateDependenciesOnList = false;
constructor(configLocation: ConfigLocation) {
this._configLocation = configLocation;
@ -113,6 +114,7 @@ export class TestServerDispatcher implements TestServerInterface {
this._closeOnDisconnect = !!params.closeOnDisconnect;
await this._setInterceptStdio(!!params.interceptStdio);
this._watchTestDirs = !!params.watchTestDirs;
this._populateDependenciesOnList = !!params.populateDependenciesOnList;
}
async ping() {}
@ -252,7 +254,7 @@ export class TestServerDispatcher implements TestServerInterface {
config.cliListOnly = true;
const status = await runTasks(new TestRun(config, reporter), [
createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false }),
createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false, populateDependencies: this._populateDependenciesOnList }),
createReportBeginTask(),
]);
return { config, report, reporter, status };

View file

@ -122,7 +122,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
});
testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report));
await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true });
await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true, populateDependenciesOnList: true });
await testServerConnection.runGlobalSetup({});
const { report } = await testServerConnection.listTests({});
@ -133,9 +133,6 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: string[], dirtyTestIds?: string[] } = { type: 'regular' };
let result: FullResult['status'] = 'passed';
// Enter the watch loop.
await runTests(options, testServerConnection);
while (true) {
printPrompt();
const readCommandPromise = readCommand();
@ -330,7 +327,7 @@ Change settings
let showBrowserServer: PlaywrightServer | undefined;
let connectWsEndpoint: string | undefined = undefined;
let seq = 0;
let seq = 1;
function printConfiguration(options: WatchModeOptions, title?: string) {
const packageManagerCommand = getPackageManagerExecCommand();
@ -344,9 +341,7 @@ function printConfiguration(options: WatchModeOptions, title?: string) {
tokens.push(...options.files.map(a => colors.bold(a)));
if (title)
tokens.push(colors.dim(`(${title})`));
if (seq)
tokens.push(colors.dim(`#${seq}`));
++seq;
tokens.push(colors.dim(`#${seq++}`));
const lines: string[] = [];
const sep = separator();
lines.push('\x1Bc' + sep);

View file

@ -131,7 +131,7 @@ export class TestTracing {
}
generateNextTraceRecordingPath() {
const file = path.join(this._artifactsDir, createGuid() + '.zip');
const file = path.join(this._artifactsDir, createGuid() + '.pwtrace.zip');
this._temporaryTraceFiles.push(file);
return file;
}
@ -214,7 +214,7 @@ export class TestTracing {
});
});
const tracePath = this._testInfo.outputPath('trace.zip');
const tracePath = this._testInfo.outputPath('trace.pwtrace.zip');
await mergeTraceFiles(tracePath, this._temporaryTraceFiles);
this._testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' });
}

View file

@ -1933,6 +1933,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
exposeBinding(params: PageExposeBindingParams, metadata?: CallMetadata): Promise<PageExposeBindingResult>;
goBack(params: PageGoBackParams, metadata?: CallMetadata): Promise<PageGoBackResult>;
goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise<PageGoForwardResult>;
forceGarbageCollection(params?: PageForceGarbageCollectionParams, metadata?: CallMetadata): Promise<PageForceGarbageCollectionResult>;
registerLocatorHandler(params: PageRegisterLocatorHandlerParams, metadata?: CallMetadata): Promise<PageRegisterLocatorHandlerResult>;
resolveLocatorHandlerNoReply(params: PageResolveLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise<PageResolveLocatorHandlerNoReplyResult>;
unregisterLocatorHandler(params: PageUnregisterLocatorHandlerParams, metadata?: CallMetadata): Promise<PageUnregisterLocatorHandlerResult>;
@ -2070,6 +2071,9 @@ export type PageGoForwardOptions = {
export type PageGoForwardResult = {
response?: ResponseChannel,
};
export type PageForceGarbageCollectionParams = {};
export type PageForceGarbageCollectionOptions = {};
export type PageForceGarbageCollectionResult = void;
export type PageRegisterLocatorHandlerParams = {
selector: string,
noWaitAfter?: boolean,

View file

@ -1430,6 +1430,8 @@ Page:
slowMo: true
snapshot: true
forceGarbageCollection:
registerLocatorHandler:
parameters:
selector: string

View file

@ -62,21 +62,21 @@ const test = baseTest.extend<BrowserTestTestFixtures, BrowserTestWorkerFixtures>
await run(playwright[browserName]);
}, { scope: 'worker' }],
allowsThirdParty: [async ({ browserName, browserMajorVersion, channel }, run) => {
allowsThirdParty: [async ({ browserName }, run) => {
if (browserName === 'firefox')
await run(true);
else
await run(false);
}, { scope: 'worker' }],
defaultSameSiteCookieValue: [async ({ browserName, browserMajorVersion, channel, isLinux }, run) => {
if (browserName === 'chromium')
defaultSameSiteCookieValue: [async ({ browserName, isLinux }, run) => {
if (browserName === 'chromium' || browserName as any === '_bidiChromium')
await run('Lax');
else if (browserName === 'webkit' && isLinux)
await run('Lax');
else if (browserName === 'webkit' && !isLinux)
await run('None');
else if (browserName === 'firefox')
else if (browserName === 'firefox' || browserName as any === '_bidiFirefox')
await run('None');
else
throw new Error('unknown browser - ' + browserName);

View file

@ -128,7 +128,7 @@ export const traceViewerFixtures: Fixtures<TraceViewerFixtures, {}, BaseTestFixt
runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => {
await use(async (body: () => Promise<void>, optsOverrides = {}) => {
const traceFile = testInfo.outputPath('trace.zip');
const traceFile = testInfo.outputPath('trace.pwtrace.zip');
await context.tracing.start({ snapshots: true, screenshots: true, sources: true, ...optsOverrides });
await body();
await context.tracing.stop({ path: traceFile });

View file

@ -58,7 +58,7 @@ test('should work when wrapped inside @playwright/test and trace is enabled', as
await expect(window).toHaveTitle(/Playwright/);
await expect(window.getByRole('heading')).toHaveText('Playwright');
const path = test.info().outputPath('electron-trace.zip');
const path = test.info().outputPath('electron-trace.pwtrace.zip');
if (trace) {
await window.context().tracing.stop({ path });
test.info().attachments.push({ name: 'trace', path, contentType: 'application/zip' });
@ -73,9 +73,9 @@ test('should work when wrapped inside @playwright/test and trace is enabled', as
});
const traces = [
// our actual trace.
path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'electron-trace.zip'),
path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'electron-trace.pwtrace.zip'),
// contains the expect() calls
path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'trace.zip'),
path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'trace.pwtrace.zip'),
];
for (const trace of traces)
expect(fs.existsSync(trace)).toBe(true);

View file

@ -960,6 +960,22 @@ it('should support application/x-www-form-urlencoded', async function({ context,
expect(params.get('file')).toBe('f.js');
});
it('should support application/x-www-form-urlencoded with param lists', async function({ context, page, server }) {
const form = new FormData();
form.append('foo', '1');
form.append('foo', '2');
const [req] = await Promise.all([
server.waitForRequest('/empty.html'),
context.request.post(server.EMPTY_PAGE, { form })
]);
expect(req.method).toBe('POST');
expect(req.headers['content-type']).toBe('application/x-www-form-urlencoded');
const body = (await req.postBody).toString('utf8');
const params = new URLSearchParams(body);
expect(req.headers['content-length']).toBe(String(params.toString().length));
expect(params.getAll('foo')).toEqual(['1', '2']);
});
it('should encode to application/json by default', async function({ context, page, server }) {
const data = {
firstName: 'John',

View file

@ -248,7 +248,7 @@ test('should reset tracing', async ({ reusedContext, trace }, testInfo) => {
page = context.pages()[0];
await page.evaluate('2 + 2');
const error = await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }).catch(e => e);
const error = await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') }).catch(e => e);
expect(error.message).toContain('Must start tracing before stopping');
});

View file

@ -489,7 +489,7 @@ test('should allow tracing over cdp session', async ({ browserType, trace }, tes
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
await page.evaluate(() => 2 + 2);
const traceZip = testInfo.outputPath('trace.zip');
const traceZip = testInfo.outputPath('trace.pwtrace.zip');
await context.tracing.stop({ path: traceZip });
await cdpBrowser.close();
expect(fs.existsSync(traceZip)).toBe(true);

View file

@ -546,6 +546,7 @@ test.describe('browser', () => {
keyPath: asset('client-certificates/client/trusted/key.pem'),
};
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
...baseOptions,

View file

@ -491,7 +491,7 @@ await page1.GotoAsync("about:blank?foo");`);
});
test('should --save-trace', async ({ runCLI }, testInfo) => {
const traceFileName = testInfo.outputPath('trace.zip');
const traceFileName = testInfo.outputPath('trace.pwtrace.zip');
const cli = runCLI([`--save-trace=${traceFileName}`], {
autoExitWhen: ' ',
});
@ -502,7 +502,7 @@ await page1.GotoAsync("about:blank?foo");`);
test('should save assets via SIGINT', async ({ runCLI, platform }, testInfo) => {
test.skip(platform === 'win32', 'SIGINT not supported on Windows');
const traceFileName = testInfo.outputPath('trace.zip');
const traceFileName = testInfo.outputPath('trace.pwtrace.zip');
const storageFileName = testInfo.outputPath('auth.json');
const harFileName = testInfo.outputPath('har.har');
const cli = runCLI([`--save-trace=${traceFileName}`, `--save-storage=${storageFileName}`, `--save-har=${harFileName}`]);

View file

@ -74,7 +74,7 @@ test.beforeAll(async function recordTrace({ browser, browserName, browserType, s
runBeforeCloseBrowserContext: async () => {
await page.hover('body');
await page.close();
traceFile = path.join(workerInfo.project.outputDir, String(workerInfo.workerIndex), browserName, 'trace.zip');
traceFile = path.join(workerInfo.project.outputDir, String(workerInfo.workerIndex), browserName, 'trace.pwtrace.zip');
await context.tracing.stop({ path: traceFile });
}
};
@ -698,7 +698,7 @@ test('should handle file URIs', async ({ page, runAndTrace, browserName }) => {
});
test('should preserve currentSrc', async ({ browser, server, showTraceViewer }) => {
const traceFile = test.info().outputPath('trace.zip');
const traceFile = test.info().outputPath('trace.pwtrace.zip');
const page = await browser.newPage({ deviceScaleFactor: 3 });
await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true });
await page.setViewportSize({ width: 300, height: 300 });
@ -1294,7 +1294,7 @@ test('should highlight locator in iframe while typing', async ({ page, runAndTra
});
test('should preserve noscript when javascript is disabled', async ({ browser, server, showTraceViewer }) => {
const traceFile = test.info().outputPath('trace.zip');
const traceFile = test.info().outputPath('trace.pwtrace.zip');
const page = await browser.newPage({ javaScriptEnabled: false });
await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true });
await page.goto(server.EMPTY_PAGE);
@ -1311,8 +1311,8 @@ test('should preserve noscript when javascript is disabled', async ({ browser, s
await expect(frame.getByText('javascript is disabled!')).toBeVisible();
});
test('should remove noscript by default', async ({ browser, server, showTraceViewer, browserType }) => {
const traceFile = test.info().outputPath('trace.zip');
test('should remove noscript by default', async ({ browser, server, showTraceViewer }) => {
const traceFile = test.info().outputPath('trace.pwtrace.zip');
const page = await browser.newPage({ javaScriptEnabled: undefined });
await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true });
await page.goto(server.EMPTY_PAGE);
@ -1329,8 +1329,8 @@ test('should remove noscript by default', async ({ browser, server, showTraceVie
await expect(frame.getByText('Enable JavaScript to run this app.')).toBeHidden();
});
test('should remove noscript when javaScriptEnabled is set to true', async ({ browser, server, showTraceViewer, browserType }) => {
const traceFile = test.info().outputPath('trace.zip');
test('should remove noscript when javaScriptEnabled is set to true', async ({ browser, server, showTraceViewer }) => {
const traceFile = test.info().outputPath('trace.pwtrace.zip');
const page = await browser.newPage({ javaScriptEnabled: true });
await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true });
await page.goto(server.EMPTY_PAGE);

View file

@ -37,9 +37,9 @@ test('should collect trace with resources, but no js', async ({ context, page, s
await page.locator('input[type="file"]').setInputFiles(asset('file-to-upload.txt'));
await page.waitForTimeout(2000); // Give it some time to produce screenshots.
await page.close();
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') });
const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
expect(events[0].type).toBe('context-options');
expect(actions).toEqual([
'page.goto',
@ -81,8 +81,8 @@ test('should use the correct apiName for event driven callbacks', async ({ conte
});
await page.evaluate(() => alert('yo'));
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') });
const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
expect(events[0].type).toBe('context-options');
expect(actions).toEqual([
'page.route',
@ -102,9 +102,9 @@ test('should not collect snapshots by default', async ({ context, page, server }
await page.setContent('<button>Click</button>');
await page.click('"Click"');
await page.close();
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') });
const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy();
expect(events.some(e => e.type === 'resource-snapshot')).toBeFalsy();
});
@ -113,8 +113,8 @@ test('should not include buffers in the trace', async ({ context, page, server }
await context.tracing.start({ snapshots: true });
await page.goto(server.PREFIX + '/empty.html');
await page.screenshot();
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { actionObjects } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') });
const { actionObjects } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
const screenshotEvent = actionObjects.find(a => a.apiName === 'page.screenshot');
expect(screenshotEvent.beforeSnapshot).toBeTruthy();
expect(screenshotEvent.afterSnapshot).toBeTruthy();
@ -129,9 +129,9 @@ test('should exclude internal pages', async ({ browserName, context, page, serve
await context.tracing.start();
await context.storageState();
await page.close();
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') });
const trace = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const trace = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
const pageIds = new Set();
trace.events.forEach(e => {
const pageId = e.pageId;
@ -144,8 +144,8 @@ test('should exclude internal pages', async ({ browserName, context, page, serve
test('should include context API requests', async ({ browserName, context, page, server }, testInfo) => {
await context.tracing.start({ snapshots: true });
await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } });
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') });
const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
const postEvent = events.find(e => e.apiName === 'apiRequestContext.post');
expect(postEvent).toBeTruthy();
const harEntry = events.find(e => e.type === 'resource-snapshot');
@ -428,9 +428,9 @@ for (const params of [
await page.setContent('<body style="box-sizing: border-box; width: 100%; height: 100%; margin:0; background: red; border: 50px solid blue"></body>');
await page.evaluate(() => new Promise(window.builtinRequestAnimationFrame));
}
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') });
const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
const frames = events.filter(e => e.type === 'screencast-frame');
// Check all frame sizes.
@ -460,10 +460,10 @@ test('should include interrupted actions', async ({ context, page, server }, tes
await page.goto(server.EMPTY_PAGE);
await page.setContent('<button>Click</button>');
page.click('"ClickNoButton"').catch(() => {});
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') });
await context.close();
const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
const clickEvent = events.find(e => e.apiName === 'page.click');
expect(clickEvent).toBeTruthy();
});
@ -475,7 +475,7 @@ test('should throw when starting with different options', async ({ context }) =>
});
test('should throw when stopping without start', async ({ context }, testInfo) => {
const error = await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }).catch(e => e);
const error = await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }).catch(e => e);
expect(error.message).toContain('Must start tracing before stopping');
});
@ -492,7 +492,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI
await page.click('"Click"');
page.click('"ClickNoButton"', { timeout: 0 }).catch(() => {});
await page.evaluate(() => {});
await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') });
await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') });
await context.tracing.startChunk();
await page.hover('"Click"');
@ -502,7 +502,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI
await page.click('"Click"');
await context.tracing.stopChunk(); // Should stop without a path.
const trace1 = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const trace1 = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
expect(trace1.events[0].type).toBe('context-options');
expect(trace1.actions).toEqual([
'page.setContent',
@ -533,7 +533,7 @@ test('should export trace concurrently to second navigation', async ({ context,
await page.waitForTimeout(timeout);
await Promise.all([
promise,
context.tracing.stop({ path: testInfo.outputPath('trace.zip') }),
context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }),
]);
}
});
@ -561,9 +561,9 @@ test('should ignore iframes in head', async ({ context, page, server }, testInfo
await context.tracing.start({ screenshots: true, snapshots: true });
await page.click('button');
await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') });
await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') });
const trace = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const trace = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip'));
expect(trace.actions).toEqual([
'page.click',
]);
@ -581,7 +581,7 @@ test('should hide internal stack frames', async ({ context, page }, testInfo) =>
await page.setContent(`<div onclick='window.alert(123)'>Click me</div>`);
await page.click('div');
await evalPromise;
const tracePath = testInfo.outputPath('trace.zip');
const tracePath = testInfo.outputPath('trace.pwtrace.zip');
await context.tracing.stop({ path: tracePath });
const trace = await parseTraceRaw(tracePath);
@ -602,7 +602,7 @@ test('should hide internal stack frames in expect', async ({ context, page }, te
await page.click('div');
await expect(page.locator('div')).toBeVisible();
await expectPromise;
const tracePath = testInfo.outputPath('trace.zip');
const tracePath = testInfo.outputPath('trace.pwtrace.zip');
await context.tracing.stop({ path: tracePath });
const trace = await parseTraceRaw(tracePath);
@ -616,7 +616,7 @@ test('should record global request trace', async ({ request, context, server },
await (request as any)._tracing.start({ snapshots: true });
const url = server.PREFIX + '/simple.json';
await request.get(url);
const tracePath = testInfo.outputPath('trace.zip');
const tracePath = testInfo.outputPath('trace.pwtrace.zip');
await (request as any)._tracing.stop({ path: tracePath });
const trace = await parseTraceRaw(tracePath);
@ -649,7 +649,7 @@ test('should store global request traces separately', async ({ request, server,
request.get(url),
request2.post(url)
]);
const tracePath = testInfo.outputPath('trace.zip');
const tracePath = testInfo.outputPath('trace.pwtrace.zip');
const trace2Path = testInfo.outputPath('trace2.zip');
await Promise.all([
(request as any)._tracing.stop({ path: tracePath }),
@ -682,7 +682,7 @@ test('should store postData for global request', async ({ request, server }, tes
await request.post(url, {
data: 'test'
});
const tracePath = testInfo.outputPath('trace.zip');
const tracePath = testInfo.outputPath('trace.pwtrace.zip');
await (request as any)._tracing.stop({ path: tracePath });
const trace = await parseTraceRaw(tracePath);
@ -755,7 +755,7 @@ test('should flush console events on tracing stop', async ({ context, page }, te
});
});
await promise;
const tracePath = testInfo.outputPath('trace.zip');
const tracePath = testInfo.outputPath('trace.pwtrace.zip');
await context.tracing.stop({ path: tracePath });
const trace = await parseTraceRaw(tracePath);
const events = trace.events.filter(e => e.type === 'console');

View file

@ -799,7 +799,7 @@ it.describe('screencast', () => {
it.fixme(!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW, 'different trace screencast image size on all browsers');
const size = { width: 500, height: 400 };
const traceFile = testInfo.outputPath('trace.zip');
const traceFile = testInfo.outputPath('trace.pwtrace.zip');
const context = await browser.newContext({
recordVideo: {

View file

@ -0,0 +1,27 @@
/**
* Copyright 2024 Adobe Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './pageTest';
test('should work', async ({ page }) => {
await page.evaluate(() => {
globalThis.objectToDestroy = {};
globalThis.weakRef = new WeakRef(globalThis.objectToDestroy);
});
await page.evaluate(() => globalThis.objectToDestroy = null);
await page.forceGarbageCollection();
expect(await page.evaluate(() => globalThis.weakRef.deref())).toBe(undefined);
});

View file

@ -29,7 +29,7 @@ export type PageWorkerFixtures = {
screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick<PageScreenshotOptions, 'fullPage' | 'omitBackground'>;
trace: 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'retain-on-first-failure' | 'on-all-retries' | /** deprecated */ 'retry-with-trace';
video: VideoMode | { mode: VideoMode, size: ViewportSize };
browserName: 'chromium' | 'firefox' | 'webkit';
browserName: 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium';
browserVersion: string;
browserMajorVersion: number;
electronMajorVersion: number;

View file

@ -262,4 +262,20 @@ test('should propagate string exception from async arrow function', { annotation
});
expect(result.output).toContain('some error');
});
});
test('should show custom message', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32582' }
}, async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('should fail', async () => {
await expect.poll(() => 1, { message: 'custom message', timeout: 500 }).toBe(2);
});
`,
});
expect(result.output).toContain('Error: custom message');
expect(result.output).toContain('Expected: 2');
expect(result.output).toContain('Received: 1');
});

View file

@ -235,25 +235,25 @@ test('should work with trace: on', async ({ runInlineTest }, testInfo) => {
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-own-context-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-own-context-passing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-passing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-persistent-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-persistent-passing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-shared-shared-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-shared-shared-passing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-two-contexts',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-two-contexts-failing',
' trace.zip',
' trace.pwtrace.zip',
]);
});
@ -271,15 +271,15 @@ test('should work with trace: retain-on-failure', async ({ runInlineTest }, test
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-own-context-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-persistent-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-shared-shared-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-two-contexts-failing',
' trace.zip',
' trace.pwtrace.zip',
]);
});
@ -297,15 +297,15 @@ test('should work with trace: on-first-retry', async ({ runInlineTest }, testInf
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-own-context-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-persistent-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-shared-shared-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-two-contexts-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
]);
});
@ -323,25 +323,25 @@ test('should work with trace: on-all-retries', async ({ runInlineTest }, testInf
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-failing-retry2',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-own-context-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-own-context-failing-retry2',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-persistent-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-persistent-failing-retry2',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-shared-shared-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-shared-shared-failing-retry2',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-two-contexts-failing-retry1',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-two-contexts-failing-retry2',
' trace.zip',
' trace.pwtrace.zip',
]);
});
@ -359,15 +359,15 @@ test('should work with trace: retain-on-first-failure', async ({ runInlineTest }
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-own-context-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-persistent-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-shared-shared-failing',
' trace.zip',
' trace.pwtrace.zip',
'artifacts-two-contexts-failing',
' trace.zip',
' trace.pwtrace.zip',
]);
});

View file

@ -114,7 +114,7 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(2);
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip'));
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.pwtrace.zip'));
expect(trace1.actionTree).toEqual([
'Before Hooks',
' fixture: browser',
@ -131,7 +131,7 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline
expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0);
expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace-1.zip'))).toBe(false);
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip'));
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.pwtrace.zip'));
expect(trace2.actionTree).toEqual([
'Before Hooks',
' fixture: context',
@ -533,6 +533,6 @@ test('should survive serial mode with tracing and reuse', async ({ runInlineTest
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(2);
expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace.pwtrace.zip'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-two', 'trace.pwtrace.zip'))).toBe(true);
});

View file

@ -53,7 +53,7 @@ test('should stop tracing with trace: on-first-retry, when not retrying', async
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(result.flaky).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-shared-flaky-retry1', 'trace.zip'))).toBeTruthy();
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-shared-flaky-retry1', 'trace.pwtrace.zip'))).toBeTruthy();
});
test('should record api trace', async ({ runInlineTest, server }, testInfo) => {
@ -86,7 +86,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => {
expect(result.passed).toBe(2);
expect(result.failed).toBe(1);
// One trace file for request context and one for each APIRequestContext
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip'));
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip'));
expect(trace1.actionTree).toEqual([
'Before Hooks',
' fixture: request',
@ -105,14 +105,14 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => {
' fixture: request',
' apiRequestContext.dispose',
]);
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.zip'));
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.pwtrace.zip'));
expect(trace2.actionTree).toEqual([
'Before Hooks',
'apiRequest.newContext',
'apiRequestContext.get',
'After Hooks',
]);
const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip'));
const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'));
expect(trace3.actionTree).toEqual([
'Before Hooks',
' fixture: request',
@ -204,7 +204,7 @@ test('should not mixup network files between contexts', async ({ runInlineTest,
}, { workers: 1, timeout: 15000 });
expect(result.exitCode).toEqual(0);
expect(result.passed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-example', 'trace.zip'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-example', 'trace.pwtrace.zip'))).toBe(true);
});
test('should save sources when requested', async ({ runInlineTest }, testInfo) => {
@ -224,7 +224,7 @@ test('should save sources when requested', async ({ runInlineTest }, testInfo) =
`,
}, { workers: 1 });
expect(result.exitCode).toEqual(0);
const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip'));
const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip'));
expect([...resources.keys()].filter(name => name.startsWith('resources/src@'))).toHaveLength(1);
});
@ -248,7 +248,7 @@ test('should not save sources when not requested', async ({ runInlineTest }, tes
`,
}, { workers: 1 });
expect(result.exitCode).toEqual(0);
const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip'));
const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip'));
expect([...resources.keys()].filter(name => name.startsWith('resources/src@'))).toHaveLength(0);
});
@ -283,8 +283,8 @@ test('should work in serial mode', async ({ runInlineTest }, testInfo) => {
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-passes', 'trace.zip'))).toBeFalsy();
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-fails', 'trace.zip'))).toBeTruthy();
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-passes', 'trace.pwtrace.zip'))).toBeFalsy();
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-fails', 'trace.pwtrace.zip'))).toBeTruthy();
});
test('should not override trace file in afterAll', async ({ runInlineTest, server }, testInfo) => {
@ -313,7 +313,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'));
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'));
expect(trace1.actionTree).toEqual([
'Before Hooks',
@ -338,7 +338,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve
]);
expect(trace1.errors).toEqual([`'oh no!'`]);
const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.zip')).catch(e => e);
const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.pwtrace.zip')).catch(e => e);
expect(error).toBeTruthy();
});
@ -366,8 +366,8 @@ test('should retain traces for interrupted tests', async ({ runInlineTest }, tes
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.interrupted).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy();
expect(fs.existsSync(testInfo.outputPath('test-results', 'b-test-2', 'trace.zip'))).toBeTruthy();
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBeTruthy();
expect(fs.existsSync(testInfo.outputPath('test-results', 'b-test-2', 'trace.pwtrace.zip'))).toBeTruthy();
});
test('should respect --trace', async ({ runInlineTest }, testInfo) => {
@ -382,7 +382,7 @@ test('should respect --trace', async ({ runInlineTest }, testInfo) => {
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy();
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBeTruthy();
});
test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInfo) => {
@ -400,7 +400,7 @@ test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInf
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBe(false);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBe(false);
});
for (const mode of ['off', 'retain-on-failure', 'on-first-retry', 'on-all-retries', 'retain-on-first-failure']) {
@ -465,7 +465,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa
});
`,
}, { trace: 'retain-on-failure' });
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.apiNames).toContain('page.goto');
expect(result.failed).toBe(1);
@ -487,7 +487,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa
});
`,
}, { trace: 'retain-on-failure' });
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.apiNames).toContain('page.goto');
expect(result.failed).toBe(1);
@ -507,7 +507,7 @@ test(`trace:retain-on-failure should create trace if request context is disposed
});
`,
}, { trace: 'retain-on-failure' });
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.apiNames).toContain('apiRequestContext.get');
expect(result.failed).toBe(1);
@ -529,7 +529,7 @@ test('should include attachments by default', async ({ runInlineTest, server },
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip'));
expect(trace.apiNames).toEqual([
'Before Hooks',
`attach "foo"`,
@ -559,7 +559,7 @@ test('should opt out of attachments', async ({ runInlineTest, server }, testInfo
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip'));
expect(trace.apiNames).toEqual([
'Before Hooks',
`attach "foo"`,
@ -592,7 +592,7 @@ test('should record with custom page fixture', async ({ runInlineTest }, testInf
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.output).toContain('failure!');
const trace = await parseTraceRaw(testInfo.outputPath('test-results', 'a-fails', 'trace.zip'));
const trace = await parseTraceRaw(testInfo.outputPath('test-results', 'a-fails', 'trace.pwtrace.zip'));
expect(trace.events).toContainEqual(expect.objectContaining({
type: 'frame-snapshot',
}));
@ -617,7 +617,7 @@ test('should expand expect.toPass', async ({ runInlineTest }, testInfo) => {
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip'));
expect(trace.actionTree).toEqual([
'Before Hooks',
' fixture: browser',
@ -656,7 +656,7 @@ test('should show non-expect error in trace', async ({ runInlineTest }, testInfo
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'));
expect(trace.actionTree).toEqual([
'Before Hooks',
' fixture: browser',
@ -692,7 +692,7 @@ test('should show error from beforeAll in trace', async ({ runInlineTest }, test
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'));
expect(trace.errors).toEqual(['Error: Oh my!']);
});
@ -730,7 +730,7 @@ test('should not throw when attachment is missing', async ({ runInlineTest }, te
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-passes', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-passes', 'trace.pwtrace.zip'));
expect(trace.actionTree).toContain('attach "screenshot"');
});
@ -754,7 +754,7 @@ test('should not throw when screenshot on failure fails', async ({ runInlineTest
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.pwtrace.zip'));
const attachedScreenshots = trace.actionTree.filter(s => s.trim() === `attach "screenshot"`);
// One screenshot for the page, no screenshot for pdf page since it should have failed.
expect(attachedScreenshots.length).toBe(1);
@ -778,7 +778,7 @@ test('should use custom expect message in trace', async ({ runInlineTest }, test
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'));
expect(trace.actionTree).toEqual([
'Before Hooks',
' fixture: browser',
@ -837,7 +837,7 @@ test('should not throw when merging traces multiple times', async ({ runInlineTe
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-foo', 'trace.zip'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-foo', 'trace.pwtrace.zip'))).toBe(true);
});
test('should record nested steps, even after timeout', async ({ runInlineTest }, testInfo) => {
@ -928,7 +928,7 @@ test('should record nested steps, even after timeout', async ({ runInlineTest },
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-example', 'trace.zip'));
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-example', 'trace.pwtrace.zip'));
expect(trace.actionTree).toEqual([
'Before Hooks',
' beforeAll hook',
@ -1022,14 +1022,14 @@ test('should attribute worker fixture teardown to the right test', async ({ runI
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-one', 'trace.zip'));
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-one', 'trace.pwtrace.zip'));
expect(trace1.actionTree).toEqual([
'Before Hooks',
' fixture: foo',
' step in foo setup',
'After Hooks',
]);
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-two', 'trace.zip'));
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-two', 'trace.pwtrace.zip'));
expect(trace2.actionTree).toEqual([
'Before Hooks',
'After Hooks',
@ -1050,11 +1050,11 @@ test('trace:retain-on-first-failure should create trace but only on first failur
`,
}, { trace: 'retain-on-first-failure', retries: 1 });
const retryTracePath = test.info().outputPath('test-results', 'a-fail-retry1', 'trace.zip');
const retryTracePath = test.info().outputPath('test-results', 'a-fail-retry1', 'trace.pwtrace.zip');
const retryTraceExists = fs.existsSync(retryTracePath);
expect(retryTraceExists).toBe(false);
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.apiNames).toContain('page.goto');
expect(result.failed).toBe(1);
@ -1071,7 +1071,7 @@ test('trace:retain-on-first-failure should create trace if context is closed bef
});
`,
}, { trace: 'retain-on-first-failure' });
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.apiNames).toContain('page.goto');
expect(result.failed).toBe(1);
@ -1090,7 +1090,7 @@ test('trace:retain-on-first-failure should create trace if context is closed bef
});
`,
}, { trace: 'retain-on-first-failure' });
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.apiNames).toContain('page.goto');
expect(result.failed).toBe(1);
@ -1107,7 +1107,7 @@ test('trace:retain-on-first-failure should create trace if request context is di
});
`,
}, { trace: 'retain-on-first-failure' });
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.apiNames).toContain('apiRequestContext.get');
expect(result.failed).toBe(1);
@ -1132,7 +1132,7 @@ test('should not corrupt actions when no library trace is present', async ({ run
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.actionTree).toEqual([
'Before Hooks',
@ -1162,7 +1162,7 @@ test('should record trace for manually created context in a failed test', async
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.actionTree).toEqual([
'Before Hooks',
@ -1204,7 +1204,7 @@ test('should not nest top level expect into unfinished api calls ', {
expect(result.exitCode).toBe(0);
expect(result.failed).toBe(0);
const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.actionTree).toEqual([
'Before Hooks',
@ -1246,7 +1246,7 @@ test('should record trace after fixture teardown timeout', {
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const tracePath = test.info().outputPath('test-results', 'a-fails', 'trace.zip');
const tracePath = test.info().outputPath('test-results', 'a-fails', 'trace.pwtrace.zip');
const trace = await parseTrace(tracePath);
expect(trace.actionTree).toEqual([
'Before Hooks',

View file

@ -66,7 +66,7 @@ test('render trace attachment', async ({ runInlineTest }) => {
test('one', async ({}, testInfo) => {
testInfo.attachments.push({
name: 'trace',
path: testInfo.outputPath('my dir with space', 'trace.zip'),
path: testInfo.outputPath('my dir with space', 'trace.pwtrace.zip'),
contentType: 'application/zip'
});
expect(1).toBe(0);
@ -75,8 +75,8 @@ test('render trace attachment', async ({ runInlineTest }) => {
}, { reporter: 'line' });
const text = result.output.replace(/\\/g, '/');
expect(text).toContain(' attachment #1: trace (application/zip) ─────────────────────────────────────────────────────────');
expect(text).toContain(' test-results/a-one/my dir with space/trace.zip');
expect(text).toContain('npx playwright show-trace "test-results/a-one/my dir with space/trace.zip"');
expect(text).toContain(' test-results/a-one/my dir with space/trace.pwtrace.zip');
expect(text).toContain('npx playwright show-trace "test-results/a-one/my dir with space/trace.pwtrace.zip"');
expect(text).toContain(' ────────────────────────────────────────────────────────────────────────────────────────────────');
expect(result.exitCode).toBe(1);
});

View file

@ -987,9 +987,12 @@ expect |expect.poll.toHaveLength @ a.test.ts:14
pw:api | page.goto(about:blank) @ a.test.ts:7
test.step | inner step attempt: 0 @ a.test.ts:8
expect | expect.toBe @ a.test.ts:10
expect | expect.toHaveLength @ a.test.ts:6
expect | error: Error: expect(received).toHaveLength(expected)
pw:api | page.goto(about:blank) @ a.test.ts:7
test.step | inner step attempt: 1 @ a.test.ts:8
expect | expect.toBe @ a.test.ts:10
expect | expect.toHaveLength @ a.test.ts:6
hook |After Hooks
fixture | fixture: page
fixture | fixture: context
@ -1036,9 +1039,12 @@ expect |expect.poll.toBe @ a.test.ts:13
expect | expect.toHaveText @ a.test.ts:7
test.step | iteration 1 @ a.test.ts:9
expect | expect.toBeVisible @ a.test.ts:10
expect | expect.toBe @ a.test.ts:6
expect | error: Error: expect(received).toBe(expected) // Object.is equality
expect | expect.toHaveText @ a.test.ts:7
test.step | iteration 2 @ a.test.ts:9
expect | expect.toBeVisible @ a.test.ts:10
expect | expect.toBe @ a.test.ts:6
hook |After Hooks
fixture | fixture: page
fixture | fixture: context

View file

@ -174,15 +174,16 @@ test('should print dependencies in mixed CJS/ESM mode 2', async ({ runInlineTest
});
});
test('should perform initial run', async ({ runWatchTest }) => {
test('should not perform initial run', async ({ runWatchTest }) => {
const testProcess = await runWatchTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
expect(testProcess.output).not.toContain('a.test.ts');
});
test('should quit on Q', async ({ runWatchTest }) => {
@ -206,7 +207,6 @@ test('should run tests on Enter', async ({ runWatchTest }) => {
test('passes', () => {});
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('\r\n');
@ -222,7 +222,6 @@ test('should run tests on R', async ({ runWatchTest }) => {
test('passes', () => {});
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('r');
@ -246,6 +245,10 @@ test('should run failed tests on F', async ({ runWatchTest }) => {
test('fails', () => { expect(1).toBe(2); });
`,
});
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.write('\r\n');
await testProcess.waitForOutput('npx playwright test #1');
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('c.test.ts:3:11 fails');
@ -253,7 +256,7 @@ test('should run failed tests on F', async ({ runWatchTest }) => {
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('f');
await testProcess.waitForOutput('npx playwright test (running failed tests) #1');
await testProcess.waitForOutput('npx playwright test (running failed tests) #2');
await testProcess.waitForOutput('c.test.ts:3:11 fails');
expect(testProcess.output).not.toContain('a.test.ts:3:11');
});
@ -269,8 +272,6 @@ test('should respect file filter P', async ({ runWatchTest }) => {
test('passes', () => {});
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('p');
@ -294,6 +295,11 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) =>
`,
};
const testProcess = await runWatchTest(files, { project: 'foo' });
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('\r\n');
await testProcess.waitForOutput('npx playwright test --project foo #1');
await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
@ -303,7 +309,7 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) =>
await testProcess.waitForOutput('bar');
testProcess.write(' ');
testProcess.write('\r\n');
await testProcess.waitForOutput('npx playwright test --project foo #1');
await testProcess.waitForOutput('npx playwright test --project foo #2');
await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('[bar] a.test.ts:3:11 passes');
@ -329,8 +335,6 @@ test('should respect file filter P and split files', async ({ runWatchTest }) =>
test('passes', () => {});
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('p');
@ -353,8 +357,6 @@ test('should respect title filter T', async ({ runWatchTest }) => {
test('title 2', () => {});
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 title 1');
await testProcess.waitForOutput('b.test.ts:3:11 title 2');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('t');
@ -381,6 +383,11 @@ test('should re-run failed tests on F > R', async ({ runWatchTest }) => {
test('fails', () => { expect(1).toBe(2); });
`,
});
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('\r\n');
await testProcess.waitForOutput('npx playwright test #1');
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('c.test.ts:3:11 fails');
@ -388,12 +395,12 @@ test('should re-run failed tests on F > R', async ({ runWatchTest }) => {
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('f');
await testProcess.waitForOutput('npx playwright test (running failed tests) #1');
await testProcess.waitForOutput('npx playwright test (running failed tests) #2');
await testProcess.waitForOutput('c.test.ts:3:11 fails');
expect(testProcess.output).not.toContain('a.test.ts:3:11');
testProcess.clearOutput();
testProcess.write('r');
await testProcess.waitForOutput('npx playwright test (re-running tests) #2');
await testProcess.waitForOutput('npx playwright test (re-running tests) #3');
await testProcess.waitForOutput('c.test.ts:3:11 fails');
expect(testProcess.output).not.toContain('a.test.ts:3:11');
});
@ -413,10 +420,6 @@ test('should run on changed files', async ({ runWatchTest, writeFiles }) => {
test('fails', () => { expect(1).toBe(2); });
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('c.test.ts:3:11 fails');
await testProcess.waitForOutput('Error: expect(received).toBe(expected)');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
await writeFiles({
@ -457,9 +460,6 @@ test('should run on changed deps', async ({ runWatchTest, writeFiles }) => {
console.log('old helper');
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:4:11 passes');
await testProcess.waitForOutput('old helper');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
await writeFiles({
@ -490,9 +490,6 @@ test('should run on changed deps in ESM', async ({ runWatchTest, writeFiles }) =
console.log('old helper');
`,
});
await testProcess.waitForOutput('a.test.ts:3:7 passes');
await testProcess.waitForOutput('b.test.ts:4:7 passes');
await testProcess.waitForOutput('old helper');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
await writeFiles({
@ -521,10 +518,6 @@ test('should re-run changed files on R', async ({ runWatchTest, writeFiles }) =>
test('fails', () => { expect(1).toBe(2); });
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('c.test.ts:3:11 fails');
await testProcess.waitForOutput('Error: expect(received).toBe(expected)');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
await writeFiles({
@ -556,8 +549,6 @@ test('should not trigger on changes to non-tests', async ({ runWatchTest, writeF
test('passes', () => {});
`,
});
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
@ -582,6 +573,10 @@ test('should only watch selected projects', async ({ runWatchTest, writeFiles })
test('passes', () => {});
`,
}, undefined, undefined, { additionalArgs: ['--project=foo'] });
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('\r\n');
await testProcess.waitForOutput('npx playwright test --project foo');
await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('[bar]');
@ -612,6 +607,10 @@ test('should watch filtered files', async ({ runWatchTest, writeFiles }) => {
test('passes', () => {});
`,
}, undefined, undefined, { additionalArgs: ['a.test.ts'] });
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('\r\n');
await testProcess.waitForOutput('npx playwright test a.test.ts');
await testProcess.waitForOutput('a.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('b.test');
@ -640,6 +639,10 @@ test('should not watch unfiltered files', async ({ runWatchTest, writeFiles }) =
test('passes', () => {});
`,
}, undefined, undefined, { additionalArgs: ['a.test.ts'] });
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('\r\n');
await testProcess.waitForOutput('npx playwright test a.test.ts');
await testProcess.waitForOutput('a.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('b.test');
@ -684,10 +687,7 @@ test('should run CT on changed deps', async ({ runWatchTest, writeFiles }) => {
});
`,
});
await testProcess.waitForOutput('button.spec.tsx:4:11 pass');
await testProcess.waitForOutput('link.spec.tsx:3:11 pass');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
await writeFiles({
'src/button.tsx': `
export const Button = () => <button>Button 2</button>;
@ -732,10 +732,7 @@ test('should run CT on indirect deps change', async ({ runWatchTest, writeFiles
});
`,
});
await testProcess.waitForOutput('button.spec.tsx:4:11 pass');
await testProcess.waitForOutput('link.spec.tsx:3:11 pass');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
await writeFiles({
'src/button.css': `
button { color: blue; }
@ -776,10 +773,7 @@ test('should run CT on indirect deps change ESM mode', async ({ runWatchTest, wr
});
`,
});
await testProcess.waitForOutput('button.spec.tsx:4:7 pass');
await testProcess.waitForOutput('link.spec.tsx:3:7 pass');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
await writeFiles({
'src/button.css': `
button { color: blue; }
@ -809,6 +803,10 @@ test('should run global teardown before exiting', async ({ runWatchTest }) => {
});
`,
});
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput();
testProcess.write('\r\n');
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.write('\x1B');