Compare commits

...

40 commits

Author SHA1 Message Date
Andrey Lushnikov 679ee37d0f chore: mark v1.32.3 2023-04-10 17:51:05 -07:00
Pavel Feldman 710674a0fe cherry-pick(#22254): revert(20509, 20596): expect.toPass is broken with these
Reverts https://github.com/microsoft/playwright/pull/20509 and
https://github.com/microsoft/playwright/pull/20596
Fixes #22215
2023-04-10 17:50:24 -07:00
Playwright Service cb419e75cd
cherry-pick(#22035): fix(webServer): follow relative redirects when checking the url (#22204)
This PR cherry-picks the following commits:

- bd698efaef

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2023-04-05 18:36:43 +02:00
Pavel Feldman ce33ec23c7 cherry-pick(#22191): chore: allow reusing browser between the tests 2023-04-04 12:40:07 -07:00
Andrey Lushnikov ed7a560ad2
chore: mark 1.32.2 (#22172) 2023-04-03 13:22:59 -07:00
Dmitry Gozman b139ffc174 cherry-pick(#22126): fix(tracing): avoid clashing network file names
With two contexts in the same test, we can get:
- `<testId>.network` and `<testId>-1.network` files;
- for export, we can copy `<testId>.network` into `<testId>-1.network`
and try to copy into a file when another trace is reading from it.

Fixes #22089.
2023-03-31 17:54:12 -07:00
Dmitry Gozman d59972aeb7 cherry-pick(#22050): fix(tracing): allow disabling tracing through env
We point `tracesDir` inside `test-results`, so it is removed between
test runs, while reused context is still writing there.
To fix the issue, we can now pass `env.PW_TEST_REUSE_CONTEXT`.

References #21993.
2023-03-31 13:38:40 -07:00
Yury Semikhatsky c132756306
cherry-pick(#22005): fix(ct): vue revert json object as prop (#22039)
Original PR: https://github.com/microsoft/playwright/pull/22005
References https://github.com/microsoft/playwright/issues/22003
2023-03-28 15:50:55 -07:00
Pavel Feldman 847b546794 cherry-pick(#21976): chore(ui): do not print global setup epilogue 2023-03-24 20:57:16 -07:00
Andrey Lushnikov 1869bd28d6 cherry-pick(#21967): chore: allow sibling describes with the same name
Fixes https://github.com/microsoft/playwright/issues/21953
2023-03-24 17:20:07 -07:00
Andrey Lushnikov 8bc3e0b6cf
chore: mark 1.32.1 (#21964) 2023-03-24 13:47:05 -07:00
Andrey Lushnikov c7d84f5f37 cherry-pick(#21866): fix(trace-viewer): survive broken selectors 2023-03-24 12:37:35 -07:00
Pavel Feldman e169cd394a cherry-pick(#21942): chore: show global setup errors in ui mode 2023-03-23 15:48:18 -07:00
Pavel Feldman 72382faddc cherry-pick(#21938): chore: filter skipped tests 2023-03-23 13:43:50 -07:00
Pavel Feldman 6b2858f0fb cherry-pick(#21935): chore: fix trace viewer backwards compat 2023-03-23 12:50:59 -07:00
Pavel Feldman a0b4bd178d cherry-pick(#21927): chore: install global watch late 2023-03-23 11:31:56 -07:00
Andrey Lushnikov f622457b33 cherry-pick(#21889): docs: do not use HTML tags 2023-03-22 13:30:05 -07:00
Andrey Lushnikov bc3ec153d3
chore: mark 1.32.0 (#21777) 2023-03-22 13:10:37 -07:00
Andrey Lushnikov 3f2640336c cherry-pick(#21886): docs: add release notes for js 2023-03-22 13:09:40 -07:00
Pavel Feldman 08422f0651 cherry-pick(#21856): chore: pack codemirror on resize 2023-03-21 18:21:39 -07:00
Dmitry Gozman 8693fd4743 cherry-pick(#21848): fix(run-server): do not engage socks when not requested
Fixes #21762.
2023-03-21 14:09:49 -07:00
Pavel Feldman 98ff2a891a cherry-pick(#21850): chore: lower the input name 2023-03-21 13:49:47 -07:00
Pavel Feldman 75b429d143 cherry-pick(#21844): chore: update test locations when merging 2023-03-21 12:14:26 -07:00
Pavel Feldman b8f802910c cherry-pick(#21843): chore(ui): show load errors 2023-03-21 12:04:13 -07:00
Pavel Feldman 39c3482980 cherry-pick(#21839): chore: sort tracing actions by wall time 2023-03-21 10:06:39 -07:00
Dmitry Gozman 0646773e85 cherry-pick(#21828): feat(snapshots): use double-buffer to avoid white flash on hover 2023-03-21 09:09:50 -07:00
Pavel Feldman e75fe015cf cherry-pick(#21830): chore(ui): use test backlog when chaining 2023-03-20 21:26:36 -07:00
Pavel Feldman 497c89dcfb cherry-pick(#21826): chore: update margins to align 2023-03-20 21:25:23 -07:00
Pavel Feldman b6e9f1fa53 cherry-pick(#21823): chore: remove npx playwright ui 2023-03-20 17:13:46 -07:00
Pavel Feldman 620310ffb2 cherry-pick(#21821): chore(ui): decorate pending, add time spent 2023-03-20 17:13:19 -07:00
Pavel Feldman eed74036e8 cherry-pick(#21809): chore(ui): queue watch runs 2023-03-20 14:49:18 -07:00
Playwright Service 80081692cd
cherry-pick(#21757): docs: add images to dependencies (#21807)
This PR cherry-picks the following commits:

- 93d20ffb52

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-20 19:16:10 +01:00
Playwright Service 2fed4b6073
cherry-pick(#21805): Revert "feat(typescript): allow declare for class properties (#21281)" (#21806)
This PR cherry-picks the following commits:

- d641caeb6a

Fixes https://github.com/microsoft/playwright/issues/21794

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-20 19:16:02 +01:00
Pavel Feldman 940078e06c cherry-pick(#21792): chore(ui): follow up to watch, fix win 2023-03-19 22:53:40 -07:00
Pavel Feldman f4f2bdd2ac cherry-pick(#21789): chore: show folders in the tree 2023-03-19 21:21:38 -07:00
Pavel Feldman 53c40e24d2 cherry-pick(#21787): chore: allow watching all tests 2023-03-19 14:50:55 -07:00
Pavel Feldman ec76a817ed cherry-pick(#21782): chore: do not pass chromium args when running carlo-alike apps 2023-03-19 12:10:51 -07:00
Pavel Feldman 82cd1789b2 cherry-pick(#21781): chore(ui): ui polish 2023-03-19 12:09:58 -07:00
Pavel Feldman 90de09668e cherry-pick(#21776): chore(ui): show test source before running 2023-03-17 21:38:35 -07:00
Pavel Feldman 3e8b14031b cherry-pick(#21775): chore: show snapshots for sync assertions 2023-03-17 20:46:55 -07:00
100 changed files with 2132 additions and 847 deletions

View file

@ -176,7 +176,7 @@ jobs:
name: 'Playwright Tests'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
@ -194,7 +194,7 @@ jobs:
name: 'Playwright Tests'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
steps:
- uses: actions/checkout@v3
- name: Set up Python
@ -218,7 +218,7 @@ jobs:
name: 'Playwright Tests'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
@ -239,7 +239,7 @@ jobs:
name: 'Playwright Tests'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
steps:
- uses: actions/checkout@v3
- name: Setup dotnet
@ -264,7 +264,7 @@ jobs:
name: 'Playwright Tests - ${{ matrix.project }} - Shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }}'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
strategy:
fail-fast: false
matrix:
@ -299,7 +299,7 @@ jobs:
- deployment: Run_E2E_Tests
pool:
vmImage: ubuntu-20.04
container: mcr.microsoft.com/playwright:v1.32.0-focal
container: mcr.microsoft.com/playwright:v1.32.3-focal
environment: testing
strategy:
runOnce:
@ -325,7 +325,7 @@ jobs:
- deployment: Run_E2E_Tests
pool:
vmImage: ubuntu-20.04
container: mcr.microsoft.com/playwright:v1.32.0-focal
container: mcr.microsoft.com/playwright:v1.32.3-focal
environment: testing
strategy:
runOnce:
@ -369,7 +369,7 @@ Running Playwright on CircleCI is very similar to running on GitHub Actions. In
executors:
pw-focal-development:
docker:
- image: mcr.microsoft.com/playwright:v1.32.0-focal
- image: mcr.microsoft.com/playwright:v1.32.3-focal
```
Note: When using the docker agent definition, you are specifying the resource class of where playwright runs to the 'medium' tier [here](https://circleci.com/docs/configuration-reference?#docker-execution-environment). The default behavior of Playwright is to set the number of workers to the detected core count (2 in the case of the medium tier). Overriding the number of workers to greater than this number will cause unnecessary timeouts and failures.
@ -403,7 +403,7 @@ to run tests on Jenkins.
```groovy
pipeline {
agent { docker { image 'mcr.microsoft.com/playwright:v1.32.0-focal' } }
agent { docker { image 'mcr.microsoft.com/playwright:v1.32.3-focal' } }
stages {
stage('e2e-tests') {
steps {
@ -421,7 +421,7 @@ pipeline {
Bitbucket Pipelines can use public [Docker images as build environments](https://confluence.atlassian.com/bitbucket/use-docker-images-as-build-environments-792298897.html). To run Playwright tests on Bitbucket, use our public Docker image ([see Dockerfile](./docker.md)).
```yml
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
```
### GitLab CI
@ -434,7 +434,7 @@ stages:
tests:
stage: test
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
script:
...
```
@ -450,7 +450,7 @@ stages:
tests:
stage: test
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
parallel: 7
script:
- npm ci
@ -465,7 +465,7 @@ stages:
tests:
stage: test
image: mcr.microsoft.com/playwright:v1.32.0-focal
image: mcr.microsoft.com/playwright:v1.32.3-focal
parallel:
matrix:
- PROJECT: ['chromium', 'webkit']

View file

@ -18,19 +18,19 @@ This Docker image is intended to be used for testing and development purposes on
### Pull the image
```bash js
docker pull mcr.microsoft.com/playwright:v1.32.0-focal
docker pull mcr.microsoft.com/playwright:v1.32.3-focal
```
```bash python
docker pull mcr.microsoft.com/playwright/python:v1.32.0-focal
docker pull mcr.microsoft.com/playwright/python:v1.32.3-focal
```
```bash csharp
docker pull mcr.microsoft.com/playwright/dotnet:v1.32.0-focal
docker pull mcr.microsoft.com/playwright/dotnet:v1.32.3-focal
```
```bash java
docker pull mcr.microsoft.com/playwright/java:v1.32.0-focal
docker pull mcr.microsoft.com/playwright/java:v1.32.3-focal
```
### Run the image
@ -42,19 +42,19 @@ By default, the Docker image will use the `root` user to run the browsers. This
On trusted websites, you can avoid creating a separate user and use root for it since you trust the code which will run on the browsers.
```bash js
docker run -it --rm --ipc=host mcr.microsoft.com/playwright:v1.32.0-focal /bin/bash
docker run -it --rm --ipc=host mcr.microsoft.com/playwright:v1.32.3-focal /bin/bash
```
```bash python
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/python:v1.32.0-focal /bin/bash
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/python:v1.32.3-focal /bin/bash
```
```bash csharp
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/dotnet:v1.32.0-focal /bin/bash
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/dotnet:v1.32.3-focal /bin/bash
```
```bash java
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:v1.32.0-focal /bin/bash
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:v1.32.3-focal /bin/bash
```
#### Crawling and scraping
@ -62,19 +62,19 @@ docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:v1.32.0-focal /
On untrusted websites, it's recommended to use a separate user for launching the browsers in combination with the seccomp profile. Inside the container or if you are using the Docker image as a base image you have to use `adduser` for it.
```bash js
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright:v1.32.0-focal /bin/bash
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright:v1.32.3-focal /bin/bash
```
```bash python
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/python:v1.32.0-focal /bin/bash
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/python:v1.32.3-focal /bin/bash
```
```bash csharp
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/dotnet:v1.32.0-focal /bin/bash
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/dotnet:v1.32.3-focal /bin/bash
```
```bash java
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/java:v1.32.0-focal /bin/bash
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/java:v1.32.3-focal /bin/bash
```
[`seccomp_profile.json`](https://github.com/microsoft/playwright/blob/main/utils/docker/seccomp_profile.json) is needed to run Chromium with sandbox. This is a [default Docker seccomp profile](https://github.com/docker/engine/blob/d0d99b04cf6e00ed3fc27e81fc3d94e7eda70af3/profiles/seccomp/default.json) with extra user namespace cloning permissions:

View file

@ -4,6 +4,26 @@ title: "Release notes"
toc_max_heading_level: 2
---
## Version 1.32
### New APIs
- New options [`option: updateMode`] and [`option: updateContent`] in [`method: Page.routeFromHAR`] and [`method: BrowserContext.routeFromHAR`].
- Chaining existing locator objects, see [locator docs](./locators.md#chaining-locators) for details.
- New option [`option: name`] in method [`method: Tracing.startChunk`].
### Browser Versions
* Chromium 112.0.5615.29
* Mozilla Firefox 111.0
* WebKit 16.4
This version was also tested against the following stable channels:
* Google Chrome 111
* Microsoft Edge 111
## Version 1.31
### New APIs

View file

@ -4,6 +4,26 @@ title: "Release notes"
toc_max_heading_level: 2
---
## Version 1.32
### New APIs
- New options [`option: updateMode`] and [`option: updateContent`] in [`method: Page.routeFromHAR`] and [`method: BrowserContext.routeFromHAR`].
- Chaining existing locator objects, see [locator docs](./locators.md#chaining-locators) for details.
- New option [`option: name`] in method [`method: Tracing.startChunk`].
### Browser Versions
* Chromium 112.0.5615.29
* Mozilla Firefox 111.0
* WebKit 16.4
This version was also tested against the following stable channels:
* Google Chrome 111
* Microsoft Edge 111
## Version 1.31
### New APIs

View file

@ -6,6 +6,47 @@ toc_max_heading_level: 2
import LiteYouTube from '@site/src/components/LiteYouTube';
## Version 1.32
### Introducing UI Mode (preview)
New UI Mode lets you explore, run and debug tests. Comes with a built-in watch mode.
![Playwright UI Mode](https://user-images.githubusercontent.com/746130/227004851-3901a691-4f8e-43d6-8d6b-cbfeafaeb999.png)
Engage with a new flag `--ui`:
```sh
npx playwright test --ui
```
### New APIs
- New options [`option: updateMode`] and [`option: updateContent`] in [`method: Page.routeFromHAR`] and [`method: BrowserContext.routeFromHAR`].
- Chaining existing locator objects, see [locator docs](./locators.md#chaining-locators) for details.
- New property [`property: TestInfo.testId`].
- New option [`option: name`] in method [`method: Tracing.startChunk`].
### ⚠️ Breaking change in component tests
Note: **component tests only**, does not affect end-to-end tests.
* `@playwright/experimental-ct-react` now supports **React 18 only**.
* If you're running component tests with React 16 or 17, please replace
`@playwright/experimental-ct-react` with `@playwright/experimental-ct-react17`.
### Browser Versions
* Chromium 112.0.5615.29
* Mozilla Firefox 111.0
* WebKit 16.4
This version was also tested against the following stable channels:
* Google Chrome 111
* Microsoft Edge 111
## Version 1.31
<LiteYouTube
@ -433,7 +474,7 @@ This version was also tested against the following stable channels:
### Announcements
* 🎁 We now ship Ubuntu 22.04 Jammy Jellyfish docker image: `mcr.microsoft.com/playwright:v1.32.0-jammy`.
* 🎁 We now ship Ubuntu 22.04 Jammy Jellyfish docker image: `mcr.microsoft.com/playwright:v1.32.3-jammy`.
* 🪦 This is the last release with macOS 10.15 support (deprecated as of 1.21).
* 🪦 This is the last release with Node.js 12 support, we recommend upgrading to Node.js LTS (16).
* ⚠️ Ubuntu 18 is now deprecated and will not be supported as of Dec 2022.
@ -684,7 +725,7 @@ Read more about [component testing with Playwright](./test-components).
}
});
```
* Playwright now runs on Ubuntu 22 amd64 and Ubuntu 22 arm64. We also publish new docker image `mcr.microsoft.com/playwright:v1.32.0-jammy`.
* Playwright now runs on Ubuntu 22 amd64 and Ubuntu 22 arm64. We also publish new docker image `mcr.microsoft.com/playwright:v1.32.3-jammy`.
### ⚠️ Breaking Changes ⚠️

View file

@ -4,6 +4,27 @@ title: "Release notes"
toc_max_heading_level: 2
---
## Version 1.32
### New APIs
- Custom expect message, see [test assertions documentation](./test-assertions.md#custom-expect-message).
- New options [`option: updateMode`] and [`option: updateContent`] in [`method: Page.routeFromHAR`] and [`method: BrowserContext.routeFromHAR`].
- Chaining existing locator objects, see [locator docs](./locators.md#chaining-locators) for details.
- New option [`option: name`] in method [`method: Tracing.startChunk`].
### Browser Versions
* Chromium 112.0.5615.29
* Mozilla Firefox 111.0
* WebKit 16.4
This version was also tested against the following stable channels:
* Google Chrome 111
* Microsoft Edge 111
## Version 1.31
### New APIs
@ -237,7 +258,7 @@ This version was also tested against the following stable channels:
### Announcements
* 🎁 We now ship Ubuntu 22.04 Jammy Jellyfish docker image: `mcr.microsoft.com/playwright/python:v1.32.0-jammy`.
* 🎁 We now ship Ubuntu 22.04 Jammy Jellyfish docker image: `mcr.microsoft.com/playwright/python:v1.32.3-jammy`.
* 🪦 This is the last release with macOS 10.15 support (deprecated as of 1.21).
* ⚠️ Ubuntu 18 is now deprecated and will not be supported as of Dec 2022.

View file

@ -595,7 +595,7 @@ export default defineConfig({
- type: ?<[Object]|[Array]<[Object]>>
- `command` <[string]> Shell command to start. For example `npm run start`..
- `port` ?<[int]> The port that your http server is expected to appear on. It does wait until it accepts connections. Exactly one of `port` or `url` is required.
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Exactly one of `port` or `url` is required.
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Exactly one of `port` or `url` is required.
- `ignoreHTTPSErrors` ?<[boolean]> Whether to ignore HTTPS errors when fetching the `url`. Defaults to `false`.
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
- `reuseExistingServer` ?<[boolean]> If true, it will re-use an existing server on the `port` or `url` when available. If no server is running on that `port` or `url`, it will run the command to start a new server. If `false`, it will throw if an existing process is listening on the `port` or `url`. This should be commonly set to `!process.env.CI` to allow the local dev server when running tests locally.

View file

@ -152,7 +152,7 @@ export default defineConfig({
```
## Dependencies
Dependencies are a list of projects that need to run before the tests in another project run. They can be useful for configuring the global setup actions so that one project depends on this running first. Using dependencies allows global setup to produce traces and other artifacts.
Dependencies are a list of projects that need to run before the tests in another project run. They can be useful for configuring the global setup actions so that one project depends on this running first. When using project dependencies, [test reporters](./test-reporters.md) will show the setup tests and the [trace viewer](/trace-viewer.md) will record traces of the setup. You can use the inspector to inspect the DOM snapshot of the trace of your setup tests and you can also use [fixtures](./test-fixtures.md) inside your setup.
In this example the chromium, firefox and webkit projects depend on the setup project.
@ -164,7 +164,7 @@ export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /global.setup\.ts/,
testMatch: '**/*.setup.ts',
},
{
name: 'chromium',
@ -185,6 +185,28 @@ export default defineConfig({
});
```
### Running Sequence
When working with tests that have a dependency, the dependency will always run first and once all tests from this project have passed, then the other projects will run in parallel.
Running order:
1. Tests in 'setup' project run
2. Tests in 'chromium', 'webkit' and 'firefox' projects run in parallel
<img width="70%" style={{display: 'flex', margin: 'auto'}} alt="chromium, webkit and firefox projects depend on setup project" loading="lazy" src="https://user-images.githubusercontent.com/13063165/225937080-327b1e63-431f-40e0-90d7-35f21d7a92cb.jpg" />
If there are more than one dependency then these project dependencies will be run first and in parallel. If the tests from a dependency fails then the tests that rely on this project will not be run.
Running order:
1. Tests in 'Browser Login' and 'DataBase' projects run in parallel
- 'Browser Login' passes
- ❌ 'DataBase' fails!
1. “e2e tests” is not run!
<img width="70%" style={{display: 'flex', margin: 'auto'}} alt="Browser login project is blue, database is red and e2e tests relies on both" loading="lazy" src="https://user-images.githubusercontent.com/13063165/225938262-33c1b78f-f092-4762-a478-7f8cbc1e3b21.jpg" />
## Custom project parameters
Projects can be also used to parametrize tests with your custom configuration - take a look at [this separate guide](./test-parameterize.md#parameterized-projects).

View file

@ -43,7 +43,7 @@ The snapshot name `example-test-1-chromium-darwin.png` consists of a few parts:
If you are not on the same operating system as your CI system, you can use Docker to generate/update the screenshots:
```bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.32.0-focal /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.32.3-focal /bin/bash
npm install
npx playwright test --update-snapshots
```

72
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "playwright-internal",
"version": "1.32.0-next",
"version": "1.32.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "playwright-internal",
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"workspaces": [
"packages/*"
@ -5955,11 +5955,11 @@
}
},
"packages/playwright": {
"version": "1.32.0-next",
"version": "1.32.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
},
"bin": {
"playwright": "cli.js"
@ -5969,11 +5969,11 @@
}
},
"packages/playwright-chromium": {
"version": "1.32.0-next",
"version": "1.32.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
},
"bin": {
"playwright": "cli.js"
@ -5983,7 +5983,7 @@
}
},
"packages/playwright-core": {
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"bin": {
"playwright": "cli.js"
@ -5994,10 +5994,10 @@
},
"packages/playwright-ct-react": {
"name": "@playwright/experimental-ct-react",
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"dependencies": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.1.1"
},
@ -6010,10 +6010,10 @@
},
"packages/playwright-ct-react17": {
"name": "@playwright/experimental-ct-react17",
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"dependencies": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.1.1"
},
@ -6026,10 +6026,10 @@
},
"packages/playwright-ct-solid": {
"name": "@playwright/experimental-ct-solid",
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"dependencies": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"vite": "^4.1.1",
"vite-plugin-solid": "^2.5.0"
},
@ -6045,10 +6045,10 @@
},
"packages/playwright-ct-svelte": {
"name": "@playwright/experimental-ct-svelte",
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"dependencies": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@sveltejs/vite-plugin-svelte": "^2.0.2",
"vite": "^4.1.1"
},
@ -6084,10 +6084,10 @@
},
"packages/playwright-ct-vue": {
"name": "@playwright/experimental-ct-vue",
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"dependencies": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.1.1"
},
@ -6136,10 +6136,10 @@
},
"packages/playwright-ct-vue2": {
"name": "@playwright/experimental-ct-vue2",
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"dependencies": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-vue2": "^2.2.0",
"vite": "^4.1.1"
},
@ -6154,11 +6154,11 @@
}
},
"packages/playwright-firefox": {
"version": "1.32.0-next",
"version": "1.32.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
},
"bin": {
"playwright": "cli.js"
@ -6169,11 +6169,11 @@
},
"packages/playwright-test": {
"name": "@playwright/test",
"version": "1.32.0-next",
"version": "1.32.3",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "*",
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
},
"bin": {
"playwright": "cli.js"
@ -6186,11 +6186,11 @@
}
},
"packages/playwright-webkit": {
"version": "1.32.0-next",
"version": "1.32.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
},
"bin": {
"playwright": "cli.js"
@ -6943,7 +6943,7 @@
"@playwright/experimental-ct-react": {
"version": "file:packages/playwright-ct-react",
"requires": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.1.1"
}
@ -6951,7 +6951,7 @@
"@playwright/experimental-ct-react17": {
"version": "file:packages/playwright-ct-react17",
"requires": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.1.1"
}
@ -6959,7 +6959,7 @@
"@playwright/experimental-ct-solid": {
"version": "file:packages/playwright-ct-solid",
"requires": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"solid-js": "^1.6.10",
"vite": "^4.1.1",
"vite-plugin-solid": "^2.5.0"
@ -6968,7 +6968,7 @@
"@playwright/experimental-ct-svelte": {
"version": "file:packages/playwright-ct-svelte",
"requires": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@sveltejs/vite-plugin-svelte": "^2.0.2",
"svelte": "^3.55.1",
"vite": "^4.1.1"
@ -6992,7 +6992,7 @@
"@playwright/experimental-ct-vue": {
"version": "file:packages/playwright-ct-vue",
"requires": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.1.1"
},
@ -7027,7 +7027,7 @@
"@playwright/experimental-ct-vue2": {
"version": "file:packages/playwright-ct-vue2",
"requires": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-vue2": "^2.2.0",
"vite": "^4.1.1",
"vue": "^2.7.14"
@ -7038,7 +7038,7 @@
"requires": {
"@types/node": "*",
"fsevents": "2.3.2",
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
},
"@sindresorhus/is": {
@ -9185,13 +9185,13 @@
"playwright": {
"version": "file:packages/playwright",
"requires": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
},
"playwright-chromium": {
"version": "file:packages/playwright-chromium",
"requires": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
},
"playwright-core": {
@ -9200,13 +9200,13 @@
"playwright-firefox": {
"version": "file:packages/playwright-firefox",
"requires": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
},
"playwright-webkit": {
"version": "file:packages/playwright-webkit",
"requires": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
},
"postcss": {

View file

@ -1,7 +1,7 @@
{
"name": "playwright-internal",
"private": true,
"version": "1.32.0-next",
"version": "1.32.3",
"description": "A high-level API to automate web browsers",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",

View file

@ -1,6 +1,6 @@
{
"name": "playwright-chromium",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "A high-level API to automate Chromium",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -28,6 +28,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "playwright-core",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "A high-level API to automate web browsers",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",

View file

@ -226,6 +226,8 @@ export class PlaywrightConnection {
}
private async _createOwnedSocksProxy(playwright: Playwright): Promise<SocksProxy | undefined> {
if (!this._options.socksProxyPattern)
return;
const socksProxy = new SocksProxy();
socksProxy.setPattern(this._options.socksProxyPattern);
playwright.options.socksProxyPort = await socksProxy.listen(0);
@ -280,4 +282,5 @@ const defaultLaunchOptions: LaunchOptions = {
const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [
'headless',
'tracesDir',
];

View file

@ -41,6 +41,9 @@ export async function syncLocalStorageWithSettings(page: Page, appName: string)
const settings = await fs.promises.readFile(settingsFile, 'utf-8').catch(() => ('{}'));
await page.addInitScript(
`(${String((settings: any) => {
// iframes w/ snapshots, etc.
if (location && location.protocol === 'data:')
return;
Object.entries(settings).map(([k, v]) => localStorage[k] = v);
(window as any).saveSettings = () => {
(window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage }));

View file

@ -246,7 +246,7 @@ export class DispatcherConnection {
const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined;
const callMetadata: CallMetadata = {
id: `call@${id}`,
wallTime: validMetadata.wallTime,
wallTime: validMetadata.wallTime || Date.now(),
location: validMetadata.location,
apiName: validMetadata.apiName,
internal: validMetadata.internal,

View file

@ -108,6 +108,7 @@ export function serverSideCallMetadata(): CallMetadata {
id: '',
startTime: 0,
endTime: 0,
wallTime: Date.now(),
type: 'Internal',
method: '',
params: {},

View file

@ -574,6 +574,7 @@ class ContextRecorder extends EventEmitter {
frameId: frame.guid,
startTime: monotonicTime(),
endTime: 0,
wallTime: Date.now(),
type: 'Frame',
method: action,
params,

View file

@ -128,8 +128,8 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
channel: findChromiumChannel(sdkLanguage),
args,
noDefaultViewport: true,
colorScheme: 'no-override',
ignoreDefaultArgs: ['--enable-automation'],
colorScheme: 'no-override',
headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed),
useWebSocket: !!process.env.PWTEST_RECORDER_PORT,
handleSIGINT,

View file

@ -261,8 +261,12 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return {};
// Network file survives across chunks, make a snapshot before returning the resulting entries.
const suffix = state.chunkOrdinal ? `-${state.chunkOrdinal}` : ``;
const networkFile = path.join(state.tracesDir, state.traceName + `${suffix}.network`);
// We should pick a name starting with "traceName" and ending with .network.
// Something like <traceName>someSuffixHere.network.
// However, this name must not clash with any other "traceName".network in the same tracesDir.
// We can use <traceName>-<guid>.network, but "-pwnetcopy-0" suffix is more readable
// and makes it easier to debug future issues.
const networkFile = path.join(state.tracesDir, state.traceName + `-pwnetcopy-${state.chunkOrdinal}.network`);
await fs.promises.copyFile(state.networkFile, networkFile);
const entries: NameValue[] = [];
@ -489,7 +493,7 @@ function createBeforeActionTraceEvent(metadata: CallMetadata): trace.BeforeActio
class: metadata.type,
method: metadata.method,
params: metadata.params,
wallTime: metadata.wallTime || Date.now(),
wallTime: metadata.wallTime,
pageId: metadata.pageId,
};
}

View file

@ -86,8 +86,8 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
channel: findChromiumChannel(traceViewerPlaywright.options.sdkLanguage),
args,
noDefaultViewport: true,
ignoreDefaultArgs: ['--enable-automation'],
headless,
ignoreDefaultArgs: ['--enable-automation'],
colorScheme: 'no-override',
useWebSocket: isUnderTest(),
});

View file

@ -80,7 +80,7 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco
const requestCallback = (res: http.IncomingMessage) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 300 && statusCode < 400 && res.headers.location)
httpRequest({ ...params, url: res.headers.location }, onResponse, onError);
httpRequest({ ...params, url: new URL.URL(res.headers.location, params.url).toString() }, onResponse, onError);
else
onResponse(res);
};

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-react",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "Playwright Component Testing for React",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -27,7 +27,7 @@
},
"dependencies": {
"@vitejs/plugin-react": "^3.1.0",
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"vite": "^4.1.1"
},
"bin": {

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-react17",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "Playwright Component Testing for React",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -27,7 +27,7 @@
},
"dependencies": {
"@vitejs/plugin-react": "^3.1.0",
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"vite": "^4.1.1"
},
"bin": {

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-solid",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "Playwright Component Testing for Solid",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -28,7 +28,7 @@
"dependencies": {
"vite": "^4.1.1",
"vite-plugin-solid": "^2.5.0",
"@playwright/test": "1.32.0-next"
"@playwright/test": "1.32.3"
},
"devDependencies": {
"solid-js": "^1.6.10"

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-svelte",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "Playwright Component Testing for Svelte",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -26,7 +26,7 @@
}
},
"dependencies": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@sveltejs/vite-plugin-svelte": "^2.0.2",
"vite": "^4.1.1"
},

View file

@ -41,14 +41,17 @@ type JsonObject = { [Key in string]?: JsonValue };
type Slot = string | string[];
export interface MountOptions<HooksConfig extends JsonObject, Props extends JsonObject> {
export interface MountOptions<
HooksConfig extends JsonObject,
Props extends Record<string, unknown>
> {
props?: Props;
slots?: Record<string, Slot> & { default?: Slot };
on?: Record<string, Function>;
hooksConfig?: HooksConfig;
}
interface MountResult<Props extends JsonObject> extends Locator {
interface MountResult<Props extends Record<string, unknown>> extends Locator {
unmount(): Promise<void>;
update(options: Omit<MountOptions<never, Props>, 'hooksConfig'>): Promise<void>;
}
@ -62,9 +65,12 @@ export interface ComponentFixtures {
mount(component: JSX.Element): Promise<MountResultJsx>;
mount<HooksConfig extends JsonObject>(
component: any,
options?: MountOptions<HooksConfig, JsonObject>
): Promise<MountResult<JsonObject>>;
mount<HooksConfig extends JsonObject, Props extends JsonObject = JsonObject>(
options?: MountOptions<HooksConfig, Record<string, unknown>>
): Promise<MountResult<Record<string, unknown>>>;
mount<
HooksConfig extends JsonObject,
Props extends Record<string, unknown> = Record<string, unknown>
>(
component: any,
options: MountOptions<HooksConfig, never> & { props: Props }
): Promise<MountResult<Props>>;

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-vue",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "Playwright Component Testing for Vue",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -27,7 +27,7 @@
},
"dependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"vite": "^4.1.1"
},
"bin": {

View file

@ -41,14 +41,17 @@ type JsonObject = { [Key in string]?: JsonValue };
type Slot = string | string[];
export interface MountOptions<HooksConfig extends JsonObject, Props extends JsonObject> {
export interface MountOptions<
HooksConfig extends JsonObject,
Props extends Record<string, unknown>
> {
props?: Props;
slots?: Record<string, Slot> & { default?: Slot };
on?: Record<string, Function>;
hooksConfig?: HooksConfig;
}
interface MountResult<Props extends JsonObject> extends Locator {
interface MountResult<Props extends Record<string, unknown>> extends Locator {
unmount(): Promise<void>;
update(options: Omit<MountOptions<never, Props>, 'hooksConfig'>): Promise<void>;
}
@ -62,9 +65,12 @@ export interface ComponentFixtures {
mount(component: JSX.Element): Promise<MountResultJsx>;
mount<HooksConfig extends JsonObject>(
component: any,
options?: MountOptions<HooksConfig, JsonObject>
): Promise<MountResult<JsonObject>>;
mount<HooksConfig extends JsonObject, Props extends JsonObject = JsonObject>(
options?: MountOptions<HooksConfig, Record<string, unknown>>
): Promise<MountResult<Record<string, unknown>>>;
mount<
HooksConfig extends JsonObject,
Props extends Record<string, unknown> = Record<string, unknown>
>(
component: any,
options: MountOptions<HooksConfig, never> & { props: Props }
): Promise<MountResult<Props>>;

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/experimental-ct-vue2",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "Playwright Component Testing for Vue2",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -26,7 +26,7 @@
}
},
"dependencies": {
"@playwright/test": "1.32.0-next",
"@playwright/test": "1.32.3",
"@vitejs/plugin-vue2": "^2.2.0",
"vite": "^4.1.1"
},

View file

@ -1,6 +1,6 @@
{
"name": "playwright-firefox",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "A high-level API to automate Firefox",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -28,6 +28,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
}

View file

@ -56,6 +56,7 @@ This project incorporates components from the projects listed below. The origina
- @babel/plugin-transform-modules-commonjs@7.19.6 (https://github.com/babel/babel)
- @babel/plugin-transform-react-jsx@7.20.7 (https://github.com/babel/babel)
- @babel/plugin-transform-typescript@7.20.2 (https://github.com/babel/babel)
- @babel/preset-typescript@7.18.6 (https://github.com/babel/babel)
- @babel/template@7.18.10 (https://github.com/babel/babel)
- @babel/traverse@7.20.1 (https://github.com/babel/babel)
- @babel/types@7.20.7 (https://github.com/babel/babel)
@ -1727,6 +1728,33 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
=========================================
END OF @babel/plugin-transform-typescript@7.20.2 AND INFORMATION
%% @babel/preset-typescript@7.18.6 NOTICES AND INFORMATION BEGIN HERE
=========================================
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
=========================================
END OF @babel/preset-typescript@7.18.6 AND INFORMATION
%% @babel/template@7.18.10 NOTICES AND INFORMATION BEGIN HERE
=========================================
MIT License
@ -4102,6 +4130,6 @@ END OF update-browserslist-db@1.0.10 AND INFORMATION
SUMMARY BEGIN HERE
=========================================
Total Packages: 142
Total Packages: 143
=========================================
END OF SUMMARY

View file

@ -28,7 +28,7 @@
"@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
"@babel/plugin-transform-modules-commonjs": "^7.19.6",
"@babel/plugin-transform-react-jsx": "^7.20.7",
"@babel/plugin-transform-typescript": "^7.20.2"
"@babel/preset-typescript": "^7.18.6"
},
"devDependencies": {
"@types/babel__code-frame": "^7.0.3",
@ -724,6 +724,22 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/preset-typescript": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz",
"integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==",
"dependencies": {
"@babel/helper-plugin-utils": "^7.18.6",
"@babel/helper-validator-option": "^7.18.6",
"@babel/plugin-transform-typescript": "^7.18.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": {
"version": "7.18.10",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
@ -1586,6 +1602,16 @@
"@babel/plugin-syntax-typescript": "^7.20.0"
}
},
"@babel/preset-typescript": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz",
"integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==",
"requires": {
"@babel/helper-plugin-utils": "^7.18.6",
"@babel/helper-validator-option": "^7.18.6",
"@babel/plugin-transform-typescript": "^7.18.6"
}
},
"@babel/template": {
"version": "7.18.10",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",

View file

@ -29,7 +29,7 @@
"@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
"@babel/plugin-transform-modules-commonjs": "^7.19.6",
"@babel/plugin-transform-react-jsx": "^7.20.7",
"@babel/plugin-transform-typescript": "^7.20.2"
"@babel/preset-typescript": "^7.18.6"
},
"devDependencies": {
"@types/babel__code-frame": "^7.0.3",

View file

@ -31,10 +31,12 @@ export function babelTransform(filename: string, isTypeScript: boolean, isModule
if (isTypeScript) {
plugins.push(
[require('@babel/plugin-proposal-class-properties')],
[require('@babel/plugin-proposal-numeric-separator')],
[require('@babel/plugin-proposal-logical-assignment-operators')],
[require('@babel/plugin-proposal-nullish-coalescing-operator')],
[require('@babel/plugin-proposal-optional-chaining')],
[require('@babel/plugin-proposal-private-methods')],
[require('@babel/plugin-syntax-json-strings')],
[require('@babel/plugin-syntax-optional-catch-binding')],
[require('@babel/plugin-syntax-async-generators')],
@ -54,10 +56,7 @@ export function babelTransform(filename: string, isTypeScript: boolean, isModule
}
}
})
],
[require('@babel/plugin-transform-typescript'), { onlyRemoveTypeImports: false, allowDeclareFields: true, isTSX: true }],
[require('@babel/plugin-proposal-class-properties')],
[require('@babel/plugin-proposal-private-methods')],
]
);
}
@ -84,7 +83,9 @@ export function babelTransform(filename: string, isTypeScript: boolean, isModule
// breaks playwright evaluates.
setPublicClassFields: true,
},
presets: [],
presets: [
[require('@babel/preset-typescript'), { onlyRemoveTypeImports: false }],
],
plugins,
sourceMaps: 'both',
} as babel.TransformOptions)!;

View file

@ -1,6 +1,6 @@
{
"name": "@playwright/test",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "A high-level API to automate web browsers",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -34,7 +34,7 @@
"license": "Apache-2.0",
"dependencies": {
"@types/node": "*",
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
},
"optionalDependencies": {
"fsevents": "2.3.2"

View file

@ -30,7 +30,6 @@ import type { FullResult } from '../reporter';
export function addTestCommands(program: Command) {
addTestCommand(program);
addUICommand(program);
addShowReportCommand(program);
addListFilesCommand(program);
}
@ -38,7 +37,7 @@ export function addTestCommands(program: Command) {
function addTestCommand(program: Command) {
const command = program.command('test [test-filter...]');
command.description('run tests with Playwright Test');
const options = [...sharedOptions, ...testOnlyOptions].sort((a, b) => a[0].replace(/-/g, '').localeCompare(b[0].replace(/-/g, '')));
const options = testOptions.sort((a, b) => a[0].replace(/-/g, '').localeCompare(b[0].replace(/-/g, '')));
options.forEach(([name, description]) => command.option(name, description));
command.action(async (args, opts) => {
try {
@ -59,30 +58,6 @@ Examples:
$ npx playwright test --project=webkit`);
}
function addUICommand(program: Command) {
const command = program.command('ui [test-filter...]');
command.description('open Playwright Test interactive UI');
sharedOptions.forEach(([name, description]) => command.option(name, description));
command.action(async (args, opts) => {
try {
opts.ui = true;
await runTests(args, opts);
} catch (e) {
console.error(e);
process.exit(1);
}
});
command.addHelpText('afterAll', `
Arguments [test-filter...]:
Pass arguments to filter test files. Each argument is treated as a regular expression. Matching is performed against the absolute file paths.
Examples:
$ npx playwright ui my.spec.ts
$ npx playwright ui some.spec.ts:42
$ npx playwright ui --headed
$ npx playwright ui --project=webkit`);
}
function addListFilesCommand(program: Command) {
const command = program.command('list-files [file-filter...]', { hidden: true });
command.description('List files with Playwright Test tests');
@ -259,35 +234,32 @@ function restartWithExperimentalTsEsm(configFile: string | null): boolean {
const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'retain-on-failure'];
const sharedOptions: [string, string][] = [
['--headed', `Run tests in headed browsers (default: headless)`],
['-c, --config <file>', `Configuration file, or a test directory with optional ${kDefaultConfigFiles.map(file => `"${file}"`).join('/')}`],
['--fully-parallel', `Run all tests in parallel (default: false)`],
['--ignore-snapshots', `Ignore screenshot and snapshot expectations`],
['-j, --workers <workers>', `Number of concurrent workers or percentage of logical CPU cores, use 1 to run in a single worker (default: 50%)`],
['--max-failures <N>', `Stop after the first N failures`],
['--no-deps', 'Do not run project dependencies'],
['--output <dir>', `Folder for output artifacts (default: "test-results")`],
['--quiet', `Suppress stdio`],
['--reporter <reporter>', `Reporter to use, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} (default: "${baseFullConfig.reporter[0]}")`],
['--project <project-name...>', `Only run tests from the specified list of projects (default: run all projects)`],
['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`],
['-x', `Stop after the first failure`],
];
const testOnlyOptions: [string, string][] = [
const testOptions: [string, string][] = [
['--browser <browser>', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`],
['-c, --config <file>', `Configuration file, or a test directory with optional ${kDefaultConfigFiles.map(file => `"${file}"`).join('/')}`],
['--debug', `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options`],
['--forbid-only', `Fail if test.only is called (default: false)`],
['--fully-parallel', `Run all tests in parallel (default: false)`],
['--global-timeout <timeout>', `Maximum time this test suite can run in milliseconds (default: unlimited)`],
['-g, --grep <grep>', `Only run tests matching this regular expression (default: ".*")`],
['-gv, --grep-invert <grep>', `Only run tests that do not match this regular expression`],
['--headed', `Run tests in headed browsers (default: headless)`],
['--ignore-snapshots', `Ignore screenshot and snapshot expectations`],
['--list', `Collect all the tests and report them, but do not run`],
['--max-failures <N>', `Stop after the first N failures`],
['--no-deps', 'Do not run project dependencies'],
['--output <dir>', `Folder for output artifacts (default: "test-results")`],
['--pass-with-no-tests', `Makes test run succeed even if no tests were found`],
['--project <project-name...>', `Only run tests from the specified list of projects (default: run all projects)`],
['--quiet', `Suppress stdio`],
['--repeat-each <N>', `Run each test N times (default: 1)`],
['--reporter <reporter>', `Reporter to use, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} (default: "${baseFullConfig.reporter[0]}")`],
['--retries <retries>', `Maximum retry count for flaky tests, zero for no retries (default: no retries)`],
['--shard <shard>', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`],
['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`],
['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`],
['--ui', `Run tests in interactive UI mode`],
['-u, --update-snapshots', `Update snapshots with actual results (default: only create missing snapshots)`],
['-j, --workers <workers>', `Number of concurrent workers or percentage of logical CPU cores, use 1 to run in a single worker (default: 50%)`],
['-x', `Stop after the first failure`],
];

View file

@ -239,7 +239,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
_snapshotSuffix: [process.platform, { scope: 'worker' }],
_setupContextOptionsAndArtifacts: [async ({ playwright, _snapshotSuffix, _combinedContextOptions, _artifactsDir, trace, screenshot, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => {
_setupContextOptionsAndArtifacts: [async ({ playwright, _contextReuseMode, _snapshotSuffix, _combinedContextOptions, _artifactsDir, trace, screenshot, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => {
if (testIdAttribute)
playwrightLibrary.selectors.setTestIdAttribute(testIdAttribute);
testInfo.snapshotSuffix = _snapshotSuffix;
@ -251,7 +251,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
const traceMode = normalizeTraceMode(trace);
const defaultTraceOptions = { screenshots: true, snapshots: true, sources: true };
const traceOptions = typeof trace === 'string' ? defaultTraceOptions : { ...defaultTraceOptions, ...trace, mode: undefined };
const captureTrace = shouldCaptureTrace(traceMode, testInfo);
const captureTrace = shouldCaptureTrace(traceMode, testInfo) && !process.env.PW_TEST_DISABLE_TRACING;
const temporaryTraceFiles: string[] = [];
const temporaryScreenshots: string[] = [];
const testInfoImpl = testInfo as TestInfoImpl;
@ -603,7 +603,7 @@ type ParsedStackTrace = {
apiName: string;
};
export function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode {
function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode {
if (!video)
return 'off';
let videoMode = typeof video === 'string' ? video : video.mode;
@ -616,7 +616,7 @@ function shouldCaptureVideo(videoMode: VideoMode, testInfo: TestInfo) {
return (videoMode === 'on' || videoMode === 'retain-on-failure' || (videoMode === 'on-first-retry' && testInfo.retry === 1));
}
export function normalizeTraceMode(trace: TraceMode | 'retry-with-trace' | { mode: TraceMode } | undefined): TraceMode {
function normalizeTraceMode(trace: TraceMode | 'retry-with-trace' | { mode: TraceMode } | undefined): TraceMode {
if (!trace)
return 'off';
let traceMode = typeof trace === 'string' ? trace : trace.mode;

View file

@ -149,7 +149,6 @@ export class TeleReporterReceiver {
}
private _onBegin(config: JsonConfig, projects: JsonProject[]) {
const removeMissing = config.listOnly;
for (const project of projects) {
let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.id === project.id);
if (!projectSuite) {
@ -159,7 +158,24 @@ export class TeleReporterReceiver {
}
const p = this._parseProject(project);
projectSuite.project = () => p;
this._mergeSuitesInto(project.suites, projectSuite, removeMissing);
this._mergeSuitesInto(project.suites, projectSuite);
// Remove deleted tests when listing. Empty suites will be auto-filtered
// in the UI layer.
if (config.listOnly) {
const testIds = new Set<string>();
const collectIds = (suite: JsonSuite) => {
suite.tests.map(t => t.testId).forEach(testId => testIds.add(testId));
suite.suites.forEach(collectIds);
};
project.suites.forEach(collectIds);
const filterTests = (suite: TeleSuite) => {
suite.tests = suite.tests.filter(t => testIds.has(t.id));
suite.suites.forEach(filterTests);
};
filterTests(projectSuite);
}
}
this._reporter.onBegin?.(this._parseConfig(config), this._rootSuite);
}
@ -171,6 +187,7 @@ export class TeleReporterReceiver {
testResult.workerIndex = payload.workerIndex;
testResult.parallelIndex = payload.parallelIndex;
testResult.startTime = new Date(payload.startTime);
testResult.statusEx = 'running';
this._reporter.onTestBegin?.(test, testResult);
}
@ -179,6 +196,7 @@ export class TeleReporterReceiver {
const result = test.resultsMap.get(payload.id)!;
result.duration = payload.duration;
result.status = payload.status;
result.statusEx = payload.status;
result.errors = payload.errors;
result.attachments = payload.attachments;
this._reporter.onTestEnd?.(test, result);
@ -260,7 +278,7 @@ export class TeleReporterReceiver {
};
}
private _mergeSuitesInto(jsonSuites: JsonSuite[], parent: TeleSuite, removeMissing: boolean) {
private _mergeSuitesInto(jsonSuites: JsonSuite[], parent: TeleSuite) {
for (const jsonSuite of jsonSuites) {
let targetSuite = parent.suites.find(s => s.title === jsonSuite.title);
if (!targetSuite) {
@ -271,16 +289,12 @@ export class TeleReporterReceiver {
targetSuite.location = jsonSuite.location;
targetSuite._fileId = jsonSuite.fileId;
targetSuite._parallelMode = jsonSuite.parallelMode;
this._mergeSuitesInto(jsonSuite.suites, targetSuite, removeMissing);
this._mergeTestsInto(jsonSuite.tests, targetSuite, removeMissing);
}
if (removeMissing) {
const suiteMap = new Map(parent.suites.map(p => [p.title, p]));
parent.suites = jsonSuites.map(s => suiteMap.get(s.title)).filter(Boolean) as TeleSuite[];
this._mergeSuitesInto(jsonSuite.suites, targetSuite);
this._mergeTestsInto(jsonSuite.tests, targetSuite);
}
}
private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite, removeMissing: boolean) {
private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) {
for (const jsonTest of jsonTests) {
let targetTest = parent.tests.find(s => s.title === jsonTest.title);
if (!targetTest) {
@ -291,16 +305,13 @@ export class TeleReporterReceiver {
}
this._updateTest(jsonTest, targetTest);
}
if (removeMissing) {
const testMap = new Map(parent.tests.map(p => [p.title, p]));
parent.tests = jsonTests.map(s => testMap.get(s.title)).filter(Boolean) as TeleTestCase[];
}
}
private _updateTest(payload: JsonTestCase, test: TeleTestCase): TeleTestCase {
test.id = payload.testId;
test.expectedStatus = payload.expectedStatus;
test.timeout = payload.timeout;
test.location = payload.location;
test.annotations = payload.annotations;
test.retries = payload.retries;
return test;
@ -355,7 +366,7 @@ export class TeleSuite implements SuitePrivate {
export class TeleTestCase implements reporterTypes.TestCase {
title: string;
fn = () => {};
results: reporterTypes.TestResult[] = [];
results: TeleTestResult[] = [];
location: Location;
parent!: TeleSuite;
@ -401,7 +412,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
this.resultsMap.clear();
}
_createTestResult(id: string): reporterTypes.TestResult {
_createTestResult(id: string): TeleTestResult {
this._clearResults();
const result: TeleTestResult = {
retry: this.results.length,
@ -413,6 +424,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
stderr: [],
attachments: [],
status: 'skipped',
statusEx: 'scheduled',
steps: [],
errors: [],
stepMap: new Map(),
@ -428,6 +440,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
export type TeleTestResult = reporterTypes.TestResult & {
stepMap: Map<string, reporterTypes.TestStep>;
stepStack: (reporterTypes.TestStep | reporterTypes.TestResult)[];
statusEx: reporterTypes.TestResult['status'] | 'scheduled' | 'running';
};
export type TeleFullProject = FullProject & { id: string };

View file

@ -19,8 +19,6 @@ import type { FrameExpectOptions } from 'playwright-core/lib/client/types';
import { colors } from 'playwright-core/lib/utilsBundle';
import type { Expect } from '../common/types';
import { expectTypes, callLogText } from '../util';
import { currentTestInfo } from '../common/globals';
import type { TestInfoErrorState } from '../worker/testInfo';
import { toBeTruthy } from './toBeTruthy';
import { toEqual } from './toEqual';
import { toExpectedTextValues, toMatchText } from './toMatchText';
@ -328,22 +326,11 @@ export async function toPass(
timeout?: number,
} = {},
) {
const testInfo = currentTestInfo();
const timeout = options.timeout !== undefined ? options.timeout : 0;
// Soft expects might mark test as failing.
// We want to revert this later if the matcher is actually passing.
// See https://github.com/microsoft/playwright/issues/20437
let testStateBeforeToPassMatcher: undefined|TestInfoErrorState;
const result = await pollAgainstTimeout<Error|undefined>(async () => {
try {
if (testStateBeforeToPassMatcher && testInfo)
testInfo._restoreErrorState(testStateBeforeToPassMatcher);
testStateBeforeToPassMatcher = testInfo?._saveErrorState();
await callback();
if (testInfo && testStateBeforeToPassMatcher && testInfo.errors.length > testStateBeforeToPassMatcher.errors.length)
return { continuePolling: !this.isNot, result: testInfo.errors[testInfo.errors.length - 1] };
return { continuePolling: this.isNot, result: undefined };
} catch (e) {
return { continuePolling: !this.isNot, result: e };
@ -361,7 +348,5 @@ export async function toPass(
return { message, pass: this.isNot };
}
if (testStateBeforeToPassMatcher && testInfo)
testInfo._restoreErrorState(testStateBeforeToPassMatcher);
return { pass: !this.isNot, message: () => '' };
}

View file

@ -124,6 +124,8 @@ export class BaseReporter implements Reporter {
protected generateStartingMessage() {
const jobs = Math.min(this.config.workers, this.config._internal.maxConcurrentTestGroups);
const shardDetails = this.config.shard ? `, shard ${this.config.shard.current} of ${this.config.shard.total}` : '';
if (!this.totalTestCount)
return '';
return '\n' + colors.dim('Running ') + this.totalTestCount + colors.dim(` test${this.totalTestCount !== 1 ? 's' : ''} using `) + jobs + colors.dim(` worker${jobs !== 1 ? 's' : ''}${shardDetails}`);
}

View file

@ -47,8 +47,11 @@ class ListReporter extends BaseReporter {
override onBegin(config: FullConfig, suite: Suite) {
super.onBegin(config, suite);
console.log(this.generateStartingMessage());
console.log();
const startingMessage = this.generateStartingMessage();
if (startingMessage) {
console.log(startingMessage);
console.log();
}
}
onTestBegin(test: TestCase, result: TestResult) {

View file

@ -18,7 +18,7 @@ import { showTraceViewer } from 'playwright-core/lib/server';
import type { Page } from 'playwright-core/lib/server/page';
import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils';
import type { FullResult } from '../../reporter';
import { clearCompilationCache, dependenciesForTestFile } from '../common/compilationCache';
import { clearCompilationCache, collectAffectedTestFiles, dependenciesForTestFile } from '../common/compilationCache';
import type { FullConfigInternal } from '../common/types';
import { Multiplexer } from '../reporters/multiplexer';
import { TeleReporterEmitter } from '../reporters/teleEmitter';
@ -28,13 +28,15 @@ import { createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForW
import { chokidar } from '../utilsBundle';
import type { FSWatcher } from 'chokidar';
import { open } from '../utilsBundle';
import ListReporter from '../reporters/list';
class UIMode {
private _config: FullConfigInternal;
private _page!: Page;
private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined;
globalCleanup: (() => Promise<FullResult['status']>) | undefined;
private _testWatcher: FSWatcher | undefined;
private _globalWatcher: Watcher;
private _testWatcher: Watcher;
private _originalStderr: (buffer: string | Uint8Array) => void;
constructor(config: FullConfigInternal) {
@ -56,28 +58,16 @@ class UIMode {
config._internal.configCLIOverrides.use.trace = { mode: 'on', sources: false };
this._originalStderr = process.stderr.write.bind(process.stderr);
this._installGlobalWatcher();
}
private _installGlobalWatcher(): FSWatcher {
const projectDirs = new Set<string>();
for (const p of this._config.projects)
projectDirs.add(p.testDir);
let coalescingTimer: NodeJS.Timeout | undefined;
const watcher = chokidar.watch([...projectDirs], { ignoreInitial: true, persistent: true }).on('all', async event => {
if (event !== 'add' && event !== 'change' && event !== 'unlink')
return;
if (coalescingTimer)
clearTimeout(coalescingTimer);
coalescingTimer = setTimeout(() => {
this._dispatchEvent({ method: 'listChanged' });
}, 200);
this._globalWatcher = new Watcher('deep', () => this._dispatchEvent({ method: 'listChanged' }));
this._testWatcher = new Watcher('flat', events => {
const collector = new Set<string>();
events.forEach(f => collectAffectedTestFiles(f.file, collector));
this._dispatchEvent({ method: 'testFilesChanged', params: { testFileNames: [...collector] } });
});
return watcher;
}
async runGlobalSetup(): Promise<FullResult['status']> {
const reporter = await createReporter(this._config, 'watch');
const reporter = new Multiplexer([new ListReporter()]);
const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter);
reporter.onConfigure(this._config);
const context: TaskRunnerState = {
@ -86,6 +76,7 @@ class UIMode {
phases: [],
};
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0);
await reporter.onExit({ status });
if (status !== 'passed') {
await globalCleanup();
return status;
@ -96,14 +87,16 @@ class UIMode {
async showUI() {
this._page = await showTraceViewer([], 'chromium', { app: 'watch.html', headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1' });
process.stdout.write = (chunk: string | Buffer) => {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) });
return true;
};
process.stderr.write = (chunk: string | Buffer) => {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) });
return true;
};
if (!process.env.PWTEST_DEBUG) {
process.stdout.write = (chunk: string | Buffer) => {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) });
return true;
};
process.stderr.write = (chunk: string | Buffer) => {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) });
return true;
};
}
const exitPromise = new ManualPromise();
this._page.on('close', () => exitPromise.resolve());
let queue = Promise.resolve();
@ -159,7 +152,13 @@ class UIMode {
const context: TaskRunnerState = { config: this._config, reporter, phases: [] };
clearCompilationCache();
reporter.onConfigure(this._config);
await taskRunner.run(context, 0);
const status = await taskRunner.run(context, 0);
await reporter.onExit({ status });
const projectDirs = new Set<string>();
for (const p of this._config.projects)
projectDirs.add(p.testDir);
this._globalWatcher.update([...projectDirs], false);
}
private async _runTests(testIds: string[]) {
@ -187,22 +186,12 @@ class UIMode {
}
private async _watchFiles(fileNames: string[]) {
if (this._testWatcher)
await this._testWatcher.close();
if (!fileNames.length)
return;
const files = new Set<string>();
for (const fileName of fileNames) {
files.add(fileName);
dependenciesForTestFile(fileName).forEach(file => files.add(file));
}
this._testWatcher = chokidar.watch([...files], { ignoreInitial: true }).on('all', async (event, file) => {
if (event !== 'add' && event !== 'change')
return;
this._dispatchEvent({ method: 'fileChanged', params: { fileName: file } });
});
this._testWatcher.update([...files], true);
}
private async _stopTests() {
@ -235,3 +224,54 @@ function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): Stdi
return { type, buffer: chunk.toString('base64') };
return { type, text: chunk };
}
type FSEvent = { event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', file: string };
class Watcher {
private _onChange: (events: FSEvent[]) => void;
private _watchedFiles: string[] = [];
private _collector: FSEvent[] = [];
private _fsWatcher: FSWatcher | undefined;
private _throttleTimer: NodeJS.Timeout | undefined;
private _mode: 'flat' | 'deep';
constructor(mode: 'flat' | 'deep', onChange: (events: FSEvent[]) => void) {
this._mode = mode;
this._onChange = onChange;
}
update(watchedFiles: string[], reportPending: boolean) {
if (JSON.stringify(this._watchedFiles) === JSON.stringify(watchedFiles))
return;
if (reportPending)
this._reportEventsIfAny();
this._watchedFiles = watchedFiles;
this._fsWatcher?.close().then(() => {});
this._fsWatcher = undefined;
this._collector.length = 0;
clearTimeout(this._throttleTimer);
this._throttleTimer = undefined;
if (!this._watchedFiles.length)
return;
this._fsWatcher = chokidar.watch(watchedFiles, { ignoreInitial: true }).on('all', async (event, file) => {
if (this._throttleTimer)
clearTimeout(this._throttleTimer);
if (this._mode === 'flat' && event !== 'add' && event !== 'change')
return;
if (this._mode === 'deep' && event !== 'add' && event !== 'change' && event !== 'unlink' && event !== 'addDir' && event !== 'unlinkDir')
return;
this._collector.push({ event, file });
this._throttleTimer = setTimeout(() => this._reportEventsIfAny(), 250);
});
}
private _reportEventsIfAny() {
if (this._collector.length)
this._onChange(this._collector.slice());
this._collector.length = 0;
}
}

View file

@ -25,12 +25,6 @@ import type { Annotation, FullConfigInternal, FullProjectInternal, Location } fr
import { getContainedPath, normalizeAndSaveAttachment, sanitizeForFilePath, serializeError, trimLongString } from '../util';
import type * as trace from '@trace/trace';
export type TestInfoErrorState = {
status: TestStatus,
errors: TestInfoError[],
hasHardError: boolean,
};
interface TestStepInternal {
complete(result: { error?: Error | TestInfoError }): void;
title: string;
@ -249,7 +243,6 @@ export class TestInfoImpl implements TestInfo {
stepId,
...data,
location,
wallTime: Date.now(),
};
this._onStepBegin(payload);
return step;
@ -269,20 +262,6 @@ export class TestInfoImpl implements TestInfo {
this.errors.push(error);
}
_saveErrorState(): TestInfoErrorState {
return {
hasHardError: this._hasHardError,
status: this.status,
errors: this.errors.slice(),
};
}
_restoreErrorState(state: TestInfoErrorState) {
this.status = state.status;
this.errors = state.errors.slice();
this._hasHardError = state.hasHardError;
}
async _runAsStep<T>(cb: () => Promise<T>, stepInfo: Omit<TestStepInternal, 'complete' | 'wallTime'>): Promise<T> {
const step = this._addStep({ ...stepInfo, wallTime: Date.now() });
try {

View file

@ -5887,7 +5887,8 @@ interface TestConfigWebServer {
/**
* The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the
* server is ready to accept connections. Exactly one of `port` or `url` is required.
* server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is
* checked. Exactly one of `port` or `url` is required.
*/
url?: string;

View file

@ -1,6 +1,6 @@
{
"name": "playwright-webkit",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "A high-level API to automate WebKit",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -28,6 +28,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "playwright",
"version": "1.32.0-next",
"version": "1.32.3",
"description": "A high-level API to automate web browsers",
"repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev",
@ -28,6 +28,6 @@
"install": "node install.js"
},
"dependencies": {
"playwright-core": "1.32.0-next"
"playwright-core": "1.32.3"
}
}

View file

@ -33,7 +33,7 @@ export type CallMetadata = {
// through the dispatcher, so is always excluded from inspector / tracing.
isServerSide?: boolean;
// Client wall time.
wallTime?: number;
wallTime: number;
location?: { file: string, line?: number, column?: number };
log: string[];
error?: SerializedError;

View file

@ -4,7 +4,7 @@
"display": "browser",
"start_url": "watch.html",
"name": "Playwright Test",
"short_name": "Trace Viewer",
"short_name": "Playwright Test",
"icons": [
{
"src": "icon-192x192.png",

View file

@ -104,7 +104,8 @@ export class SnapshotRenderer {
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
html = prefix + [
'<style>*,*::before,*::after { visibility: hidden }</style>',
`<style>*[__playwright_target__="${this._callId}"] { background-color: #6fa8dc7f; }</style>`,
`<style>*[__playwright_target__="${this.snapshotName}"] { outline: 2px solid #006ab1 !important; background-color: #6fa8dc7f !important; }</style>`,
`<style>*[__playwright_target__="${this._callId}"] { outline: 2px solid #006ab1 !important; background-color: #6fa8dc7f !important; }</style>`,
`<script>${snapshotScript()}</script>`
].join('') + html;

View file

@ -42,15 +42,13 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
try {
await traceModel.load(traceUrl, progress);
} catch (error: any) {
// eslint-disable-next-line no-console
console.error(error);
if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html'))
throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.');
else if (traceFileName)
// eslint-disable-next-line no-console
console.error(error);
if (traceFileName)
throw new Error(`Could not load trace from ${traceFileName}. Make sure to upload a valid Playwright trace.`);
else
throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`);
throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`);
}
const snapshotServer = new SnapshotServer(traceModel.storage());
loadedTraces.set(traceUrl, { traceModel, snapshotServer });

View file

@ -279,9 +279,9 @@ export class TraceModel {
params: metadata.params,
wallTime: metadata.wallTime || Date.now(),
log: metadata.log,
beforeSnapshot: metadata.snapshots.find(s => s.snapshotName === 'before')?.snapshotName,
inputSnapshot: metadata.snapshots.find(s => s.snapshotName === 'input')?.snapshotName,
afterSnapshot: metadata.snapshots.find(s => s.snapshotName === 'after')?.snapshotName,
beforeSnapshot: metadata.snapshots.find(s => s.title === 'before')?.snapshotName,
inputSnapshot: metadata.snapshots.find(s => s.title === 'input')?.snapshotName,
afterSnapshot: metadata.snapshots.find(s => s.title === 'after')?.snapshotName,
error: metadata.error?.error,
result: metadata.result,
point: metadata.point,

View file

@ -26,7 +26,7 @@
flex: none;
align-items: center;
margin: 0 4px;
color: var(--gray);
color: var(--vscode-editorCodeLens-foreground);
}
.action-icon {

View file

@ -60,7 +60,7 @@ const renderAction = (
revealConsole: () => void
) => {
const { errors, warnings } = modelUtil.stats(action);
const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector) : undefined;
const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector, false /* isFrameLocator */, true /* playSafe */) : undefined;
let time: string = '';
if (action.endTime)

View file

@ -95,7 +95,7 @@ function propertyToString(event: ActionTraceEvent, name: string, value: any, sdk
if ((name === 'value' && isEval) || (name === 'received' && event.method === 'expect'))
value = parseSerializedValue(value, new Array(10).fill({ handle: '<handle>' }));
if (name === 'selector')
return { text: asLocator(sdkLanguage || 'javascript', event.params.selector), type: 'locator', name: 'locator' };
return { text: asLocator(sdkLanguage || 'javascript', event.params.selector, false /* isFrameLocator */, true /* playSafe */), type: 'locator', name: 'locator' };
const type = typeof value;
if (type !== 'object' || value === null)
return { text: String(value), type, name };

View file

@ -17,8 +17,7 @@
import './filmStrip.css';
import type { Boundaries, Size } from '../geometry';
import * as React from 'react';
import { useMeasure } from './helpers';
import { upperBound } from '@web/uiUtils';
import { useMeasure, upperBound } from '@web/uiUtils';
import type { PageEntry } from '../entries';
import type { MultiTraceModel } from './modelUtil';

View file

@ -1,55 +0,0 @@
/*
Copyright (c) Microsoft Corporation.
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 * as React from 'react';
// Recalculates the value when dependencies change.
export function useAsyncMemo<T>(fn: () => Promise<T>, deps: React.DependencyList, initialValue: T, resetValue?: T) {
const [value, setValue] = React.useState<T>(initialValue);
React.useEffect(() => {
let canceled = false;
if (resetValue !== undefined)
setValue(resetValue);
fn().then(value => {
if (!canceled)
setValue(value);
});
return () => {
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return value;
}
// Tracks the element size and returns it's contentRect (always has x=0, y=0).
export function useMeasure<T extends Element>() {
const ref = React.useRef<T | null>(null);
const [measure, setMeasure] = React.useState(new DOMRect(0, 0, 10, 10));
React.useLayoutEffect(() => {
const target = ref.current;
if (!target)
return;
const resizeObserver = new ResizeObserver((entries: any) => {
const entry = entries[entries.length - 1];
if (entry && entry.contentRect)
setMeasure(entry.contentRect);
});
resizeObserver.observe(target);
return () => resizeObserver.unobserve(target);
}, [ref]);
return [measure, ref] as const;
}

View file

@ -22,7 +22,8 @@ import type { ContextEntry, PageEntry } from '../entries';
import type { SerializedError, StackFrame } from '@protocol/channels';
const contextSymbol = Symbol('context');
const nextSymbol = Symbol('next');
const nextInContextSymbol = Symbol('next');
const prevInListSymbol = Symbol('prev');
const eventsSymbol = Symbol('events');
const resourcesSymbol = Symbol('resources');
@ -65,9 +66,8 @@ export class MultiTraceModel {
this.events = ([] as EventTraceEvent[]).concat(...contexts.map(c => c.events));
this.hasSource = contexts.some(c => c.hasSource);
this.actions.sort((a1, a2) => a1.startTime - a2.startTime);
this.events.sort((a1, a2) => a1.time - a2.time);
this.actions = dedupeActions(this.actions);
this.actions = dedupeAndSortActions(this.actions);
this.sources = collectSources(this.actions);
}
}
@ -78,13 +78,13 @@ function indexModel(context: ContextEntry) {
for (let i = 0; i < context.actions.length; ++i) {
const action = context.actions[i] as any;
action[contextSymbol] = context;
action[nextSymbol] = context.actions[i + 1];
action[nextInContextSymbol] = context.actions[i + 1];
}
for (const event of context.events)
(event as any)[contextSymbol] = context;
}
function dedupeActions(actions: ActionTraceEvent[]) {
function dedupeAndSortActions(actions: ActionTraceEvent[]) {
const callActions = actions.filter(a => a.callId.startsWith('call@'));
const expectActions = actions.filter(a => a.callId.startsWith('expect@'));
@ -114,15 +114,22 @@ function dedupeActions(actions: ActionTraceEvent[]) {
result.push(expectAction);
}
return result.sort((a1, a2) => a1.startTime - a2.startTime);
result.sort((a1, a2) => (a1.wallTime - a2.wallTime));
for (let i = 1; i < result.length; ++i)
(result[i] as any)[prevInListSymbol] = result[i - 1];
return result;
}
export function context(action: ActionTraceEvent): ContextEntry {
return (action as any)[contextSymbol];
}
function next(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextSymbol];
function nextInContext(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextInContextSymbol];
}
export function prevInList(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[prevInListSymbol];
}
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
@ -149,7 +156,7 @@ export function eventsForAction(action: ActionTraceEvent): EventTraceEvent[] {
if (result)
return result;
const nextAction = next(action);
const nextAction = nextInContext(action);
result = context(action).events.filter(event => {
return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime);
});
@ -162,7 +169,7 @@ export function resourcesForAction(action: ActionTraceEvent): ResourceSnapshot[]
if (result)
return result;
const nextAction = next(action);
const nextAction = nextInContext(action);
result = context(action).resources.filter(resource => {
return typeof resource._monotonicTime === 'number' && resource._monotonicTime > action.startTime && (!nextAction || resource._monotonicTime < nextAction.startTime);
});

View file

@ -24,6 +24,10 @@
overflow: hidden;
}
.snapshot-tab .toolbar {
background-color: var(--vscode-sideBar-background);
}
.snapshot-controls {
flex: none;
background-color: var(--vscode-sideBar-background);
@ -72,11 +76,25 @@
box-shadow: 0 12px 28px 0 rgba(0,0,0,.2),0 2px 4px 0 rgba(0,0,0,.1);
}
iframe#snapshot {
.snapshot-switcher {
width: 100%;
height: calc(100% - var(--window-header-height));
position: relative;
}
iframe[name=snapshot] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
background: white;
visibility: hidden;
}
iframe.snapshot-visible[name=snapshot] {
visibility: visible;
}
.no-snapshot {
@ -153,4 +171,5 @@ body.dark-mode .window-header {
.snapshot-tab .cm-wrapper {
line-height: 23px;
margin-right: 4px;
}

View file

@ -16,13 +16,12 @@
import './snapshotTab.css';
import * as React from 'react';
import { useMeasure } from './helpers';
import type { ActionTraceEvent } from '@trace/trace';
import { context } from './modelUtil';
import { context, prevInList } from './modelUtil';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton } from '@web/components/toolbarButton';
import { copy } from '@web/uiUtils';
import { copy, useMeasure } from '@web/uiUtils';
import { InjectedScript } from '@injected/injectedScript';
import { Recorder } from '@injected/recorder';
import { asLocator } from '@isomorphic/locatorGenerators';
@ -36,75 +35,102 @@ export const SnapshotTab: React.FunctionComponent<{
testIdAttributeName: string,
}> = ({ action, sdkLanguage, testIdAttributeName }) => {
const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action');
const [isInspecting, setIsInspecting] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const [pickerVisible, setPickerVisible] = React.useState(false);
const { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => {
const actionSnapshot = action?.inputSnapshot || action?.afterSnapshot;
const snapshots = [
actionSnapshot ? { title: 'action', snapshotName: actionSnapshot } : undefined,
action?.beforeSnapshot ? { title: 'before', snapshotName: action?.beforeSnapshot } : undefined,
action?.afterSnapshot ? { title: 'after', snapshotName: action.afterSnapshot } : undefined,
].filter(Boolean) as { title: string, snapshotName: string }[];
const { snapshots } = React.useMemo(() => {
if (!action)
return { snapshots: {} };
let snapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';
let popoutUrl: string | undefined;
let snapshotInfoUrl: string | undefined;
let pointX: number | undefined;
let pointY: number | undefined;
if (action) {
const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotName) {
const params = new URLSearchParams();
params.set('trace', context(action).traceUrl);
params.set('name', snapshot.snapshotName);
snapshotUrl = new URL(`snapshot/${action.pageId}?${params.toString()}`, window.location.href).toString();
snapshotInfoUrl = new URL(`snapshotInfo/${action.pageId}?${params.toString()}`, window.location.href).toString();
if (snapshot.title === 'action') {
pointX = action.point?.x;
pointY = action.point?.y;
}
const popoutParams = new URLSearchParams();
popoutParams.set('r', snapshotUrl);
popoutParams.set('trace', context(action).traceUrl);
popoutUrl = new URL(`popout.html?${popoutParams.toString()}`, window.location.href).toString();
}
// if the action has no beforeSnapshot, use the last available afterSnapshot.
let beforeSnapshot = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined;
let a = action;
while (!beforeSnapshot && a) {
a = prevInList(a);
beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined;
}
const afterSnapshot = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot;
const actionSnapshot = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot } : afterSnapshot;
return { snapshots: { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot } };
}, [action]);
const { snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => {
const snapshot = snapshots[snapshotTab];
if (!snapshot)
return { snapshotUrl: kBlankSnapshotUrl };
const params = new URLSearchParams();
params.set('trace', context(snapshot.action).traceUrl);
params.set('name', snapshot.snapshotName);
const snapshotUrl = new URL(`snapshot/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
const snapshotInfoUrl = new URL(`snapshotInfo/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
const pointX = snapshotTab === 'action' ? snapshot.action.point?.x : undefined;
const pointY = snapshotTab === 'action' ? snapshot.action.point?.y : undefined;
const popoutParams = new URLSearchParams();
popoutParams.set('r', snapshotUrl);
popoutParams.set('trace', context(snapshot.action).traceUrl);
const popoutUrl = new URL(`popout.html?${popoutParams.toString()}`, window.location.href).toString();
return { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl };
}, [action, snapshotIndex]);
}, [snapshots, snapshotTab]);
React.useEffect(() => {
if (snapshots.length >= 1 && snapshotIndex >= snapshots.length)
setSnapshotIndex(snapshots.length - 1);
}, [snapshotIndex, snapshots]);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const iframeRef0 = React.useRef<HTMLIFrameElement>(null);
const iframeRef1 = React.useRef<HTMLIFrameElement>(null);
const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' });
const loadingRef = React.useRef({ iteration: 0, visibleIframe: 0 });
React.useEffect(() => {
(async () => {
const thisIteration = loadingRef.current.iteration + 1;
const newVisibleIframe = 1 - loadingRef.current.visibleIframe;
loadingRef.current.iteration = thisIteration;
const newSnapshotInfo = { url: '', viewport: kDefaultViewport };
if (snapshotInfoUrl) {
const response = await fetch(snapshotInfoUrl);
const info = await response.json();
if (!info.error)
setSnapshotInfo(info);
} else {
setSnapshotInfo({ viewport: kDefaultViewport, url: '' });
if (!info.error) {
newSnapshotInfo.url = info.url;
newSnapshotInfo.viewport = info.viewport;
}
}
if (!iframeRef.current)
// Interrupted by another load - bail out.
if (loadingRef.current.iteration !== thisIteration)
return;
try {
const newUrl = snapshotUrl + (pointX === undefined ? '' : `&pointX=${pointX}&pointY=${pointY}`);
// Try preventing history entry from being created.
if (iframeRef.current.contentWindow)
iframeRef.current.contentWindow.location.replace(newUrl);
else
iframeRef.current.src = newUrl;
} catch (e) {
const iframe = [iframeRef0, iframeRef1][newVisibleIframe].current;
if (iframe) {
let loadedCallback = () => {};
const loadedPromise = new Promise<void>(f => loadedCallback = f);
try {
iframe.addEventListener('load', loadedCallback);
iframe.addEventListener('error', loadedCallback);
const newUrl = snapshotUrl + (pointX === undefined ? '' : `&pointX=${pointX}&pointY=${pointY}`);
// Try preventing history entry from being created.
if (iframe.contentWindow)
iframe.contentWindow.location.replace(newUrl);
else
iframe.src = newUrl;
await loadedPromise;
} catch {
} finally {
iframe.removeEventListener('load', loadedCallback);
iframe.removeEventListener('error', loadedCallback);
}
}
// Interrupted by another load - bail out.
if (loadingRef.current.iteration !== thisIteration)
return;
loadingRef.current.visibleIframe = newVisibleIframe;
setSnapshotInfo(newSnapshotInfo);
})();
}, [iframeRef, snapshotUrl, snapshotInfoUrl, pointX, pointY]);
}, [snapshotUrl, snapshotInfoUrl, pointX, pointY]);
const windowHeaderHeight = 40;
const snapshotContainerSize = {
@ -133,20 +159,26 @@ export const SnapshotTab: React.FunctionComponent<{
testIdAttributeName={testIdAttributeName}
highlightedLocator={highlightedLocator}
setHighlightedLocator={setHighlightedLocator}
iframe={iframeRef.current} />
iframe={iframeRef0.current} />
<InspectModeController
isInspecting={isInspecting}
sdkLanguage={sdkLanguage}
testIdAttributeName={testIdAttributeName}
highlightedLocator={highlightedLocator}
setHighlightedLocator={setHighlightedLocator}
iframe={iframeRef1.current} />
<Toolbar>
<ToolbarButton title='Pick locator' disabled={!popoutUrl} toggled={pickerVisible} onClick={() => {
setPickerVisible(!pickerVisible);
setHighlightedLocator('');
setIsInspecting(!pickerVisible);
}}>Pick locator</ToolbarButton>
<div style={{ width: 5 }}></div>
{snapshots.map((snapshot, index) => {
{['action', 'before', 'after'].map(tab => {
return <TabbedPaneTab
id={snapshot.title}
title={renderTitle(snapshot.title)}
selected={snapshotIndex === index}
onSelect={() => setSnapshotIndex(index)}
id={tab}
title={renderTitle(tab)}
selected={snapshotTab === tab}
onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')}
></TabbedPaneTab>;
})}
<div style={{ flex: 'auto' }}></div>
@ -154,7 +186,7 @@ export const SnapshotTab: React.FunctionComponent<{
window.open(popoutUrl || '', '_blank');
}}></ToolbarButton>
</Toolbar>
{pickerVisible && <Toolbar>
{pickerVisible && <Toolbar noMinHeight={true}>
<ToolbarButton icon='microscope' title='Pick locator' disabled={!popoutUrl} toggled={isInspecting} onClick={() => {
setIsInspecting(!isInspecting);
}}></ToolbarButton>
@ -168,7 +200,7 @@ export const SnapshotTab: React.FunctionComponent<{
}}></ToolbarButton>
</Toolbar>}
<div ref={ref} className='snapshot-wrapper'>
{ snapshots.length ? <div className='snapshot-container' style={{
<div className='snapshot-container' style={{
width: snapshotContainerSize.width + 'px',
height: snapshotContainerSize.height + 'px',
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
@ -188,9 +220,11 @@ export const SnapshotTab: React.FunctionComponent<{
</div>
</div>
</div>
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe>
</div> : <div className='no-snapshot'>Action does not have snapshots</div>
}
<div className='snapshot-switcher'>
<iframe ref={iframeRef0} name='snapshot' className={loadingRef.current.visibleIframe === 0 ? 'snapshot-visible' : ''}></iframe>
<iframe ref={iframeRef1} name='snapshot' className={loadingRef.current.visibleIframe === 1 ? 'snapshot-visible' : ''}></iframe>
</div>
</div>
</div>
</div>;
};
@ -215,15 +249,23 @@ export const InspectModeController: React.FunctionComponent<{
}> = ({ iframe, isInspecting, sdkLanguage, testIdAttributeName, highlightedLocator, setHighlightedLocator }) => {
React.useEffect(() => {
const win = iframe?.contentWindow as any;
if (!win || !isInspecting && !highlightedLocator && !win._recorder)
let recorder: Recorder | undefined;
try {
if (!win)
return;
recorder = win._recorder;
if (!recorder && !isInspecting && !highlightedLocator)
return;
} catch {
// Potential cross-origin exception when accessing win._recorder.
return;
let recorder: Recorder | undefined = win._recorder;
}
if (!recorder) {
const injectedScript = new InjectedScript(win, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
recorder = new Recorder(injectedScript, {
async setSelector(selector: string) {
recorder!.setUIState({ mode: 'none', language: sdkLanguage, testIdAttributeName });
setHighlightedLocator(asLocator('javascript', selector, false));
setHighlightedLocator(asLocator('javascript', selector, false /* isFrameLocator */, true /* playSafe */));
}
});
win._recorder = recorder;
@ -240,3 +282,4 @@ export const InspectModeController: React.FunctionComponent<{
};
const kDefaultViewport = { width: 1280, height: 720 };
const kBlankSnapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';

View file

@ -21,3 +21,13 @@
display: flex;
flex-direction: row;
}
.source-tab-file-name {
height: 24px;
margin-left: 8px;
display: flex;
align-items: center;
background-color: var(--vscode-breadcrumb-background);
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px;
z-index: 10;
}

View file

@ -17,18 +17,21 @@
import type { ActionTraceEvent } from '@trace/trace';
import { SplitView } from '@web/components/splitView';
import * as React from 'react';
import { useAsyncMemo } from './helpers';
import { useAsyncMemo } from '@web/uiUtils';
import './sourceTab.css';
import { StackTraceView } from './stackTrace';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import type { SourceHighlight } from '@web/components/codeMirrorWrapper';
import type { SourceModel } from './modelUtil';
import type { StackFrame } from '@protocol/channels';
export const SourceTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined,
sources: Map<string, SourceModel>,
hideStackFrames?: boolean,
}> = ({ action, sources, hideStackFrames }) => {
rootDir?: string,
fallbackLocation?: StackFrame,
}> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => {
const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
@ -39,31 +42,42 @@ export const SourceTab: React.FunctionComponent<{
}
}, [action, lastAction, setLastAction, setSelectedFrame]);
const source = useAsyncMemo<SourceModel>(async () => {
const file = action?.stack?.[selectedFrame].file;
if (!file)
return { errors: [], content: undefined };
const source = sources.get(file)!;
if (source.content === undefined) {
const sha1 = await calculateSha1(file);
const { source, highlight, targetLine, fileName } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[] }>(async () => {
const location = action?.stack?.[selectedFrame] || fallbackLocation;
if (!location?.file)
return { source: { errors: [], content: undefined }, targetLine: 0, highlight: [] };
let source = sources.get(location.file);
// Fallback location can fall outside the sources model.
if (!source) {
source = { errors: [], content: undefined };
sources.set(location.file, source);
}
const targetLine = location.line || 0;
const fileName = rootDir && location.file.startsWith(rootDir) ? location.file.substring(rootDir.length + 1) : location.file;
const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.location.line, message: e.error!.message }));
highlight.push({ line: targetLine, type: 'running' });
if (source.content === undefined || fallbackLocation) {
const sha1 = await calculateSha1(location.file);
try {
let response = await fetch(`sha1/src@${sha1}.txt`);
if (response.status === 404)
response = await fetch(`file?path=${file}`);
response = await fetch(`file?path=${location.file}`);
source.content = await response.text();
} catch {
source.content = `<Unable to read "${file}">`;
source.content = `<Unable to read "${location.file}">`;
}
}
return source;
}, [action, selectedFrame], { errors: [], content: 'Loading\u2026' });
const targetLine = action?.stack?.[selectedFrame]?.line || 0;
const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.location.line, message: e.error!.message }));
highlight.push({ line: targetLine, type: 'running' });
return { source, highlight, targetLine, fileName };
}, [action, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] });
return <SplitView sidebarSize={200} orientation='horizontal' sidebarHidden={hideStackFrames}>
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
<div className='vbox' data-testid='source-code'>
{fileName && <div className='source-tab-file-name'>{fileName}</div>}
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
</div>
<StackTraceView action={action} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />
</SplitView>;
};

View file

@ -127,7 +127,7 @@
.timeline-bar.frame_waitforeventinfo,
.timeline-bar.page_waitforeventinfo {
--action-color: var(--gray);
--action-color: var(--vscode-editorCodeLens-foreground);
}
.timeline-label {

View file

@ -16,11 +16,10 @@
*/
import type { ActionTraceEvent, EventTraceEvent } from '@trace/trace';
import { msToString } from '@web/uiUtils';
import { msToString, useMeasure } from '@web/uiUtils';
import * as React from 'react';
import type { Boundaries } from '../geometry';
import { FilmStrip } from './filmStrip';
import { useMeasure } from './helpers';
import type { MultiTraceModel } from './modelUtil';
import './timeline.css';

View file

@ -37,19 +37,24 @@
.watch-mode-list-item-title {
flex: auto;
text-overflow: ellipsis;
overflow: hidden
overflow: hidden;
}
.watch-mode-sidebar .toolbar {
min-height: 24px;
.watch-mode-list-item-time {
flex: none;
color: var(--vscode-editorCodeLens-foreground);
margin: 0 4px;
user-select: none;
}
.watch-mode-sidebar .toolbar-button {
margin: 0;
.list-view-entry.selected .watch-mode-list-item-time,
.list-view-entry.highlighted .watch-mode-list-item-time {
display: none;
}
.watch-mode .section-title {
display: flex;
flex: auto;
flex-direction: row;
align-items: center;
font-size: 11px;
@ -58,30 +63,30 @@
text-overflow: ellipsis;
overflow: hidden;
padding: 8px;
height: 30px;
}
.watch-mode-sidebar img {
flex: none;
margin: 0 4px;
margin-left: 6px;
width: 24px;
height: 24px;
}
.status-line {
flex: none;
flex: auto;
white-space: nowrap;
line-height: 22px;
padding: 0 10px;
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBar-background);
padding-left: 10px;
display: flex;
flex-direction: row;
align-items: center;
height: 30px;
}
.status-line > div {
display: flex;
align-items: center;
margin: 0 5px;
overflow: hidden;
text-overflow: ellipsis;
}
.list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) {
@ -103,7 +108,11 @@
flex: none;
display: flex;
flex-direction: column;
margin-top: 8px;
margin: 2px 0;
}
.filter-list {
padding: 0 10px 10px 10px;
}
.filter-title,
@ -121,14 +130,17 @@
.filter-summary {
line-height: 24px;
margin-top: 2px;
margin-left: 20px;
margin-left: 24px;
}
.filter-summary .filter-label {
margin-left: 5px;
}
.filter-entry {
line-height: 24px;
}
.filter-entry label {
display: flex;
align-items: center;

View file

@ -22,7 +22,7 @@ import { TreeView } from '@web/components/treeView';
import type { TreeState } from '@web/components/treeView';
import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver';
import type { TeleTestCase } from '@testIsomorphic/teleReceiver';
import type { FullConfig, Suite, TestCase, TestResult, Location } from '../../../playwright-test/types/testReporter';
import type { FullConfig, Suite, TestCase, Location, TestError } from '../../../playwright-test/types/testReporter';
import { SplitView } from '@web/components/splitView';
import { MultiTraceModel } from './modelUtil';
import './watchMode.css';
@ -34,10 +34,10 @@ import { XtermWrapper } from '@web/components/xtermWrapper';
import { Expandable } from '@web/components/expandable';
import { toggleTheme } from '@web/theme';
import { artifactsFolderName } from '@testIsomorphic/folders';
import { settings } from '@web/uiUtils';
import { msToString, settings, useSetting } from '@web/uiUtils';
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress) => void = () => {};
let runWatchedTests = (fileName: string) => {};
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {};
let runWatchedTests = (fileNames: string[]) => {};
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
@ -67,17 +67,22 @@ export const WatchModeView: React.FC<{}> = ({
]));
const [projectFilters, setProjectFilters] = React.useState<Map<string, boolean>>(new Map());
const [testModel, setTestModel] = React.useState<TestModel>({ config: undefined, rootSuite: undefined });
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0, skipped: 0 });
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>(undefined);
const [visibleTestIds, setVisibleTestIds] = React.useState<string[]>([]);
const [progress, setProgress] = React.useState<Progress & { total: number } | undefined>();
const [selectedItem, setSelectedItem] = React.useState<{ location?: Location, testCase?: TestCase }>({});
const [visibleTestIds, setVisibleTestIds] = React.useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean }>();
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>();
const [watchAll, setWatchAll] = useSetting<boolean>('watch-all', false);
const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() });
const runTestPromiseChain = React.useRef(Promise.resolve());
const runTestBacklog = React.useRef<Set<string>>(new Set());
const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = () => {
setIsLoading(true);
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), { total: 0, passed: 0, failed: 0, skipped: 0 });
setWatchedTreeIds({ value: new Set() });
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined);
refreshRootSuite(true).then(() => {
setIsLoading(false);
});
@ -88,7 +93,7 @@ export const WatchModeView: React.FC<{}> = ({
reloadTests();
}, []);
updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress) => {
updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress | undefined) => {
const selectedProjects = config.configFile ? settings.getObject<string[] | undefined>(config.configFile + ':projects', undefined) : undefined;
for (const projectName of projectFilters.keys()) {
if (!rootSuite.suites.find(s => s.title === projectName))
@ -103,25 +108,38 @@ export const WatchModeView: React.FC<{}> = ({
setTestModel({ config, rootSuite });
setProjectFilters(new Map(projectFilters));
setProgress(newProgress);
if (runningState && newProgress)
setProgress({ ...newProgress, total: runningState.testIds.size });
else if (!newProgress)
setProgress(undefined);
};
const runTests = (testIds: string[]) => {
// Clear test results.
{
const testIdSet = new Set(testIds);
for (const test of testModel.rootSuite?.allTests() || []) {
if (testIdSet.has(test.id))
(test as TeleTestCase)._createTestResult('pending');
}
setTestModel({ ...testModel });
}
const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set<string>) => {
if (mode === 'bounce-if-busy' && runningState)
return;
const time = ' [' + new Date().toLocaleTimeString() + ']';
xtermDataSource.write('\x1B[2m—'.repeat(Math.max(0, xtermSize.cols - time.length)) + time + '\x1B[22m');
setProgress({ total: testIds.length, passed: 0, failed: 0, skipped: 0 });
setRunningState({ testIds: new Set(testIds) });
sendMessage('run', { testIds }).then(() => {
runTestBacklog.current = new Set([...runTestBacklog.current, ...testIds]);
runTestPromiseChain.current = runTestPromiseChain.current.then(async () => {
const testIds = runTestBacklog.current;
runTestBacklog.current = new Set();
if (!testIds.size)
return;
// Clear test results.
{
for (const test of testModel.rootSuite?.allTests() || []) {
if (testIds.has(test.id))
(test as TeleTestCase)._createTestResult('pending');
}
setTestModel({ ...testModel });
}
const time = ' [' + new Date().toLocaleTimeString() + ']';
xtermDataSource.write('\x1B[2m—'.repeat(Math.max(0, xtermSize.cols - time.length)) + time + '\x1B[22m');
setProgress({ total: testIds.size, passed: 0, failed: 0, skipped: 0 });
setRunningState({ testIds });
await sendMessage('run', { testIds: [...testIds] });
// Clear pending tests in case of interrupt.
for (const test of testModel.rootSuite?.allTests() || []) {
if (test.results[0]?.duration === -1)
@ -130,33 +148,31 @@ export const WatchModeView: React.FC<{}> = ({
setTestModel({ ...testModel });
setRunningState(undefined);
});
};
}, [runningState, testModel]);
const isRunningTest = !!runningState;
const result = selectedTest?.results[0];
const outputDir = selectedTest ? outputDirForTestCase(selectedTest) : undefined;
return <div className='vbox watch-mode'>
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<div className='vbox'>
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}>
<Toolbar>
<div className='section-title' style={{ flex: 'none' }}>Output</div>
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>
<div className='spacer'></div>
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
</Toolbar>
<XtermWrapper source={xtermDataSource}></XtermWrapper>;
<XtermWrapper source={xtermDataSource}></XtermWrapper>
</div>
<div className={'vbox' + (isShowingOutput ? ' hidden' : '')}>
<TraceView outputDir={outputDir} testCase={selectedTest} result={result} />
<TraceView item={selectedItem} rootDir={testModel.config?.rootDir} />
</div>
</div>
<div className='vbox watch-mode-sidebar'>
<Toolbar>
<Toolbar noShadow={true} noMinHeight={true}>
<img src='icon-32x32.png' />
<div className='section-title'>Playwright</div>
<div className='spacer'></div>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()} />
<ToolbarButton icon='color-mode' title='Toggle color mode' onClick={() => toggleTheme()} />
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
</Toolbar>
@ -168,12 +184,18 @@ export const WatchModeView: React.FC<{}> = ({
projectFilters={projectFilters}
setProjectFilters={setProjectFilters}
testModel={testModel}
runTests={() => runTests(visibleTestIds)} />
<Toolbar>
<div className='section-title'>Tests</div>
<div className='spacer'></div>
<ToolbarButton icon='play' title='Run all' onClick={() => runTests(visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
runTests={() => runTests('bounce-if-busy', visibleTestIds)} />
<Toolbar noMinHeight={true}>
{!isRunningTest && !progress && <div className='section-title'>Tests</div>}
{!isRunningTest && progress && <div data-testid='status-line' className='status-line'>
<div>{progress.passed}/{progress.total} passed ({(progress.passed / progress.total) * 100 | 0}%)</div>
</div>}
{isRunningTest && progress && <div data-testid='status-line' className='status-line'>
<div>Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)</div>
</div>}
<ToolbarButton icon='play' title='Run all' onClick={() => runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch all' toggled={watchAll} onClick={() => setWatchAll(!watchAll)}></ToolbarButton>
</Toolbar>
<TestList
statusFilters={statusFilters}
@ -182,19 +204,14 @@ export const WatchModeView: React.FC<{}> = ({
testModel={testModel}
runningState={runningState}
runTests={runTests}
onTestSelected={setSelectedTest}
setVisibleTestIds={setVisibleTestIds} />
onItemSelected={setSelectedItem}
setVisibleTestIds={setVisibleTestIds}
watchAll={watchAll}
watchedTreeIds={watchedTreeIds}
setWatchedTreeIds={setWatchedTreeIds}
isLoading={isLoading} />
</div>
</SplitView>
<div className='status-line'>
<div>Total: {progress.total}</div>
{isRunningTest && <div><span className='codicon codicon-loading'></span>{`Running ${visibleTestIds.length}\u2026`}</div>}
{isLoading && <div><span className='codicon codicon-loading'></span> {'Loading\u2026'}</div>}
{!isRunningTest && <div>Showing: {visibleTestIds.length}</div>}
<div>{progress.passed} passed</div>
<div>{progress.failed} failed</div>
<div>{progress.skipped} skipped</div>
</div>
</div>;
};
@ -228,40 +245,43 @@ const FiltersView: React.FC<{
if (e.key === 'Enter')
runTests();
}} />}>
{<div className='filter-title' title={statusLine} onClick={() => setExpanded(false)}><span className='filter-label'>Status:</span> {statusLine}</div>}
{[...statusFilters.entries()].map(([status, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(statusFilters);
copy.set(status, !copy.get(status));
setStatusFilters(copy);
}}/>
<div>{status}</div>
</label>
</div>;
})}
{<div className='filter-title' title={projectsLine}><span className='filter-label'>Projects:</span> {projectsLine}</div>}
{[...projectFilters.entries()].map(([projectName, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(projectFilters);
copy.set(projectName, !copy.get(projectName));
setProjectFilters(copy);
const configFile = testModel?.config?.configFile;
if (configFile)
settings.setObject(configFile + ':projects', [...copy.entries()].filter(([_, v]) => v).map(([k]) => k));
}}/>
<div>{projectName}</div>
</label>
</div>;
})}
</Expandable>
{!expanded && <div className='filter-summary' title={'Status: ' + statusLine + '\nProjects: ' + projectsLine} onClick={() => setExpanded(true)}>
<div className='filter-summary' title={'Status: ' + statusLine + '\nProjects: ' + projectsLine} onClick={() => setExpanded(!expanded)}>
<span className='filter-label'>Status:</span> {statusLine}
<span className='filter-label'>Projects:</span> {projectsLine}
</div>
{expanded && <div className='hbox' style={{ marginLeft: 14 }}>
<div className='filter-list'>
{[...statusFilters.entries()].map(([status, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(statusFilters);
copy.set(status, !copy.get(status));
setStatusFilters(copy);
}}/>
<div>{status}</div>
</label>
</div>;
})}
</div>
<div className='filter-list'>
{[...projectFilters.entries()].map(([projectName, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(projectFilters);
copy.set(projectName, !copy.get(projectName));
setProjectFilters(copy);
const configFile = testModel?.config?.configFile;
if (configFile)
settings.setObject(configFile + ':projects', [...copy.entries()].filter(([_, v]) => v).map(([k]) => k));
}}/>
<div>{projectName}</div>
</label>
</div>;
})}
</div>
</div>}
</div>;
};
@ -273,34 +293,44 @@ const TestList: React.FC<{
projectFilters: Map<string, boolean>,
filterText: string,
testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined },
runTests: (testIds: string[]) => void,
runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
setVisibleTestIds: (testIds: string[]) => void,
onTestSelected: (test: TestCase | undefined) => void,
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, onTestSelected, setVisibleTestIds }) => {
watchAll: boolean,
watchedTreeIds: { value: Set<string> },
setWatchedTreeIds: (ids: { value: Set<string> }) => void,
isLoading?: boolean,
setVisibleTestIds: (testIds: Set<string>) => void,
onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void,
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [watchedTreeIds] = React.useState<Set<string>>(new Set());
const { rootItem, treeItemMap } = React.useMemo(() => {
const rootItem = createTree(testModel.rootSuite, projectFilters);
filterTree(rootItem, filterText, statusFilters);
// Build the test tree.
const { rootItem, treeItemMap, fileNames } = React.useMemo(() => {
let rootItem = createTree(testModel.rootSuite, projectFilters);
filterTree(rootItem, filterText, statusFilters, runningState?.testIds);
sortAndPropagateStatus(rootItem);
rootItem = shortenRoot(rootItem);
hideOnlyTests(rootItem);
const treeItemMap = new Map<string, TreeItem>();
const visibleTestIds = new Set<string>();
const fileNames = new Set<string>();
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'group' && treeItem.location.file)
fileNames.add(treeItem.location.file);
if (treeItem.kind === 'case')
treeItem.tests.forEach(t => visibleTestIds.add(t.id));
treeItem.children.forEach(visit);
treeItemMap.set(treeItem.id, treeItem);
};
visit(rootItem);
setVisibleTestIds([...visibleTestIds]);
return { rootItem, treeItemMap };
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds]);
setVisibleTestIds(visibleTestIds);
return { rootItem, treeItemMap, fileNames };
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]);
// Look for a first failure within the run batch to select it.
React.useEffect(() => {
// Look for a first failure within the run batch to select it.
if (!runningState || runningState.itemSelectedByUser)
return;
let selectedTreeItem: TreeItem | undefined;
@ -321,39 +351,61 @@ const TestList: React.FC<{
setSelectedTreeItemId(selectedTreeItem.id);
}, [runningState, setSelectedTreeItemId, rootItem]);
// Compute selected item.
const { selectedTreeItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
const location = selectedTreeItem?.location;
let selectedTest: TestCase | undefined;
if (selectedTreeItem?.kind === 'test')
selectedTest = selectedTreeItem.test;
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1)
selectedTest = selectedTreeItem.tests[0];
onTestSelected(selectedTest);
onItemSelected({ testCase: selectedTest, location });
return { selectedTreeItem };
}, [onTestSelected, selectedTreeItemId, treeItemMap]);
}, [onItemSelected, selectedTreeItemId, treeItemMap]);
const setWatchedTreeIds = (watchedTreeIds: Set<string>) => {
const fileNames = new Set<string>();
for (const itemId of watchedTreeIds) {
const treeItem = treeItemMap.get(itemId)!;
fileNames.add(fileNameForTreeItem(treeItem)!);
// Update watch all.
React.useEffect(() => {
if (watchAll) {
sendMessageNoReply('watch', { fileNames: [...fileNames] });
} else {
const fileNames = new Set<string>();
for (const itemId of watchedTreeIds.value) {
const treeItem = treeItemMap.get(itemId);
const fileName = treeItem?.location.file;
if (fileName)
fileNames.add(fileName);
}
sendMessageNoReply('watch', { fileNames: [...fileNames] });
}
sendMessageNoReply('watch', { fileNames: [...fileNames] });
};
}, [rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]);
const runTreeItem = (treeItem: TreeItem) => {
setSelectedTreeItemId(treeItem.id);
runTests(collectTestIds(treeItem));
runTests('bounce-if-busy', collectTestIds(treeItem));
};
runWatchedTests = (fileName: string) => {
runWatchedTests = (changedTestFiles: string[]) => {
const testIds: string[] = [];
for (const treeId of watchedTreeIds) {
const treeItem = treeItemMap.get(treeId)!;
if (fileNameForTreeItem(treeItem) === fileName)
testIds.push(...collectTestIds(treeItem));
const set = new Set(changedTestFiles);
if (watchAll) {
const visit = (treeItem: TreeItem) => {
const fileName = treeItem.location.file;
if (fileName && set.has(fileName))
testIds.push(...collectTestIds(treeItem));
if (treeItem.kind === 'group' && treeItem.subKind === 'folder')
treeItem.children.forEach(visit);
};
visit(rootItem);
} else {
for (const treeId of watchedTreeIds.value) {
const treeItem = treeItemMap.get(treeId);
const fileName = treeItem?.location.file;
if (fileName && set.has(fileName))
testIds.push(...collectTestIds(treeItem));
}
}
runTests(testIds);
runTests('queue-if-busy', new Set(testIds));
};
return <TestTreeView
@ -364,18 +416,23 @@ const TestList: React.FC<{
render={treeItem => {
return <div className='hbox watch-mode-list-item'>
<div className='watch-mode-list-item-title'>{treeItem.title}</div>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch' onClick={() => {
if (watchedTreeIds.has(treeItem.id))
watchedTreeIds.delete(treeItem.id);
else
watchedTreeIds.add(treeItem.id);
setWatchedTreeIds(watchedTreeIds);
}} toggled={watchedTreeIds.has(treeItem.id)}></ToolbarButton>
{!!treeItem.duration && treeItem.status !== 'skipped' && <div className='watch-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
<Toolbar noMinHeight={true} noShadow={true}>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
{!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => {
if (watchedTreeIds.value.has(treeItem.id))
watchedTreeIds.value.delete(treeItem.id);
else
watchedTreeIds.value.add(treeItem.id);
setWatchedTreeIds({ ...watchedTreeIds });
}} toggled={watchedTreeIds.value.has(treeItem.id)}></ToolbarButton>}
</Toolbar>
</div>;
}}
icon={treeItem => {
if (treeItem.status === 'scheduled')
return 'codicon-clock';
if (treeItem.status === 'running')
return 'codicon-loading';
if (treeItem.status === 'failed')
@ -394,18 +451,23 @@ const TestList: React.FC<{
setSelectedTreeItemId(treeItem.id);
}}
autoExpandDeep={!!filterText}
noItemsMessage='No tests' />;
noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />;
};
const TraceView: React.FC<{
outputDir: string | undefined,
testCase: TestCase | undefined,
result: TestResult | undefined,
}> = ({ outputDir, testCase, result }) => {
item: { location?: Location, testCase?: TestCase },
rootDir?: string,
}> = ({ item, rootDir }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
const { outputDir, result } = React.useMemo(() => {
const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined;
const result = item.testCase?.results[0];
return { outputDir, result };
}, [item]);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
@ -427,7 +489,7 @@ const TraceView: React.FC<{
return;
}
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${testCase?.id}.json`;
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`;
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
@ -443,9 +505,16 @@ const TraceView: React.FC<{
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [result, outputDir, testCase, setModel, counter, setCounter]);
}, [result, outputDir, item, setModel, counter, setCounter]);
return <Workbench key='workbench' model={model} hideTimelineBars={true} hideStackFrames={true} showSourcesFirst={true} />;
return <Workbench
key='workbench'
model={model}
hideTimelineBars={true}
hideStackFrames={true}
showSourcesFirst={true}
rootDir={rootDir}
defaultSourceLocation={item.location} />;
};
declare global {
@ -478,7 +547,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
let rootSuite: Suite;
const progress: Progress = {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
@ -489,7 +557,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
if (!rootSuite)
rootSuite = suite;
config = c;
progress.total = suite.allTests().length;
progress.passed = 0;
progress.failed = 0;
progress.skipped = 0;
@ -513,6 +580,10 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
++progress.passed;
throttleUpdateRootSuite(config, rootSuite, progress);
},
onError: (error: TestError) => {
xtermDataSource.write((error.stack || error.value || '') + '\n');
},
});
return sendMessage('list', {});
};
@ -523,8 +594,8 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
return;
}
if (message.method === 'fileChanged') {
runWatchedTests(message.params.fileName);
if (message.method === 'testFilesChanged') {
runWatchedTests(message.params.testFileNames);
return;
}
@ -546,6 +617,10 @@ const sendMessage = async (method: string, params: any) => {
};
const sendMessageNoReply = (method: string, params?: any) => {
if ((window as any)._overrideProtocolForTest) {
(window as any)._overrideProtocolForTest({ method, params }).catch(() => {});
return;
}
sendMessage(method, params).catch((e: Error) => {
// eslint-disable-next-line no-console
console.error(e);
@ -560,25 +635,22 @@ const outputDirForTestCase = (testCase: TestCase): string | undefined => {
return undefined;
};
const fileNameForTreeItem = (treeItem?: TreeItem): string | undefined => {
return treeItem?.location.file;
};
const locationToOpen = (treeItem?: TreeItem) => {
if (!treeItem)
return;
return treeItem.location.file + ':' + treeItem.location.line;
};
const collectTestIds = (treeItem?: TreeItem): string[] => {
const collectTestIds = (treeItem?: TreeItem): Set<string> => {
const testIds = new Set<string>();
if (!treeItem)
return [];
const testIds: string[] = [];
return testIds;
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'case')
testIds.push(...treeItem.tests.map(t => t.id));
treeItem.tests.map(t => t.id).forEach(id => testIds.add(id));
else if (treeItem.kind === 'test')
testIds.push(treeItem.id);
testIds.add(treeItem.id);
else
treeItem.children?.forEach(visit);
};
@ -587,7 +659,6 @@ const collectTestIds = (treeItem?: TreeItem): string[] => {
};
type Progress = {
total: number;
passed: number;
failed: number;
skipped: number;
@ -598,13 +669,15 @@ type TreeItemBase = {
id: string;
title: string;
location: Location,
duration: number;
parent: TreeItem | undefined;
children: TreeItem[];
status: 'none' | 'running' | 'passed' | 'failed' | 'skipped';
status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped';
};
type GroupItem = TreeItemBase & {
kind: 'group',
kind: 'group';
subKind: 'folder' | 'file' | 'describe';
children: (TestCaseItem | GroupItem)[];
};
@ -622,13 +695,39 @@ type TestItem = TreeItemBase & {
type TreeItem = GroupItem | TestCaseItem | TestItem;
function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, fileItems: Map<string, GroupItem>): GroupItem {
if (filePath.length === 0)
return rootItem;
const fileName = filePath.join(pathSeparator);
const existingFileItem = fileItems.get(fileName);
if (existingFileItem)
return existingFileItem;
const parentFileItem = getFileItem(rootItem, filePath.slice(0, filePath.length - 1), false, fileItems);
const fileItem: GroupItem = {
kind: 'group',
subKind: isFile ? 'file' : 'folder',
id: fileName,
title: filePath[filePath.length - 1],
location: { file: fileName, line: 0, column: 0 },
duration: 0,
parent: parentFileItem,
children: [],
status: 'none',
};
parentFileItem.children.push(fileItem);
fileItems.set(fileName, fileItem);
return fileItem;
}
function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, boolean>): GroupItem {
const filterProjects = [...projectFilters.values()].some(Boolean);
const rootItem: GroupItem = {
kind: 'group',
subKind: 'folder',
id: 'root',
title: '',
location: { file: '', line: 0, column: 0 },
duration: 0,
parent: undefined,
children: [],
status: 'none',
@ -636,14 +735,16 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => {
for (const suite of parentSuite.suites) {
const title = suite.title;
const title = suite.title || '<anonymous>';
let group = parentGroup.children.find(item => item.title === title) as GroupItem | undefined;
if (!group) {
group = {
kind: 'group',
subKind: 'describe',
id: parentGroup.id + '\x1e' + title,
title,
location: suite.location!,
duration: 0,
parent: parentGroup,
children: [],
status: 'none',
@ -665,21 +766,25 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
children: [],
tests: [],
location: test.location,
duration: 0,
status: 'none',
};
parentGroup.children.push(testCaseItem);
}
let status: 'none' | 'running' | 'passed' | 'failed' | 'skipped' = 'none';
if (test.results.some(r => r.duration === -1))
const result = (test as TeleTestCase).results[0];
let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none';
if (result?.statusEx === 'scheduled')
status = 'scheduled';
else if (result?.statusEx === 'running')
status = 'running';
else if (test.results.length && test.results[0].status === 'skipped')
else if (result?.status === 'skipped')
status = 'skipped';
else if (test.results.length && test.results[0].status === 'interrupted')
else if (result?.status === 'interrupted')
status = 'none';
else if (test.results.length && test.outcome() !== 'expected')
else if (result && test.outcome() !== 'expected')
status = 'failed';
else if (test.results.length && test.outcome() === 'expected')
else if (result && test.outcome() === 'expected')
status = 'passed';
testCaseItem.tests.push(test);
@ -692,58 +797,36 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
parent: testCaseItem,
children: [],
status,
project: projectName
duration: test.results.length ? Math.max(0, test.results[0].duration) : 0,
project: projectName,
});
testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0);
}
};
const fileMap = new Map<string, GroupItem>();
for (const projectSuite of rootSuite?.suites || []) {
if (filterProjects && !projectFilters.get(projectSuite.title))
continue;
visitSuite(projectSuite.title, projectSuite, rootItem);
}
const sortAndPropagateStatus = (treeItem: TreeItem) => {
for (const child of treeItem.children)
sortAndPropagateStatus(child);
if (treeItem.kind === 'group' && treeItem.parent)
treeItem.children.sort((a, b) => a.location.line - b.location.line);
let allPassed = treeItem.children.length > 0;
let allSkipped = treeItem.children.length > 0;
let hasFailed = false;
let hasRunning = false;
for (const child of treeItem.children) {
allSkipped = allSkipped && child.status === 'skipped';
allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped');
hasFailed = hasFailed || child.status === 'failed';
hasRunning = hasRunning || child.status === 'running';
for (const fileSuite of projectSuite.suites) {
const fileItem = getFileItem(rootItem, fileSuite.location!.file.split(pathSeparator), true, fileMap);
visitSuite(projectSuite.title, fileSuite, fileItem);
}
if (hasRunning)
treeItem.status = 'running';
else if (hasFailed)
treeItem.status = 'failed';
else if (allSkipped)
treeItem.status = 'skipped';
else if (allPassed)
treeItem.status = 'passed';
};
sortAndPropagateStatus(rootItem);
}
return rootItem;
}
function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<string, boolean>) {
function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<string, boolean>, runningTestIds: Set<string> | undefined) {
const tokens = filterText.trim().toLowerCase().split(' ');
const filtersStatuses = [...statusFilters.values()].some(Boolean);
const filter = (testCase: TestCaseItem) => {
const title = testCase.tests[0].titlePath().join(' ').toLowerCase();
if (!tokens.every(token => title.includes(token)))
if (!tokens.every(token => title.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id)))
return false;
testCase.children = (testCase.children as TestItem[]).filter(test => !filtersStatuses || statusFilters.get(test.status));
testCase.children = (testCase.children as TestItem[]).filter(test => {
return !filtersStatuses || runningTestIds?.has(test.id) || statusFilters.get(test.status);
});
testCase.tests = (testCase.children as TestItem[]).map(c => c.test);
return !!testCase.children.length;
};
@ -765,6 +848,51 @@ function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<
visit(rootItem);
}
function sortAndPropagateStatus(treeItem: TreeItem) {
for (const child of treeItem.children)
sortAndPropagateStatus(child);
if (treeItem.kind === 'group') {
treeItem.children.sort((a, b) => {
const fc = a.location.file.localeCompare(b.location.file);
return fc || a.location.line - b.location.line;
});
}
let allPassed = treeItem.children.length > 0;
let allSkipped = treeItem.children.length > 0;
let hasFailed = false;
let hasRunning = false;
let hasScheduled = false;
for (const child of treeItem.children) {
allSkipped = allSkipped && child.status === 'skipped';
allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped');
hasFailed = hasFailed || child.status === 'failed';
hasRunning = hasRunning || child.status === 'running';
hasScheduled = hasScheduled || child.status === 'scheduled';
}
if (hasRunning)
treeItem.status = 'running';
else if (hasScheduled)
treeItem.status = 'scheduled';
else if (hasFailed)
treeItem.status = 'failed';
else if (allSkipped)
treeItem.status = 'skipped';
else if (allPassed)
treeItem.status = 'passed';
}
function shortenRoot(rootItem: GroupItem): GroupItem {
let shortRoot = rootItem;
while (shortRoot.children.length === 1 && shortRoot.children[0].kind === 'group' && shortRoot.children[0].subKind === 'folder')
shortRoot = shortRoot.children[0];
shortRoot.location = rootItem.location;
return shortRoot;
}
function hideOnlyTests(rootItem: GroupItem) {
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'case' && treeItem.children.length === 1)
@ -782,3 +910,5 @@ async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}
const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/';

View file

@ -30,19 +30,24 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { Timeline } from './timeline';
import './workbench.css';
import { MetadataView } from './metadataView';
import type { Location } from '../../../playwright-test/types/testReporter';
export const Workbench: React.FunctionComponent<{
model?: MultiTraceModel,
hideTimelineBars?: boolean,
hideStackFrames?: boolean,
showSourcesFirst?: boolean,
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst }) => {
rootDir?: string,
defaultSourceLocation?: Location,
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, defaultSourceLocation }) => {
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>(showSourcesFirst ? 'source' : 'call');
const activeAction = model ? highlightedAction || selectedAction : undefined;
const sources = React.useMemo(() => model?.sources || new Map(), [model]);
React.useEffect(() => {
if (selectedAction && model?.actions.includes(selectedAction))
return;
@ -66,7 +71,12 @@ export const Workbench: React.FunctionComponent<{
const sourceTab: TabbedPaneTabModel = {
id: 'source',
title: 'Source',
render: () => <SourceTab action={activeAction} sources={model?.sources || new Map()} hideStackFrames={hideStackFrames}/>
render: () => <SourceTab
action={activeAction}
sources={sources}
hideStackFrames={hideStackFrames}
rootDir={rootDir}
fallbackLocation={defaultSourceLocation} />
};
const consoleTab: TabbedPaneTabModel = {
id: 'console',

View file

@ -145,6 +145,10 @@ body.dark-mode ::-webkit-scrollbar-track:hover {
animation: spin 1s infinite linear;
}
::placeholder {
color: var(--vscode-input-placeholderForeground);
}
@keyframes spin {
100% {
transform: rotate(360deg);

View file

@ -1,6 +1,7 @@
[*]
../theme.ts
../third_party/vscode/codicon.css
../uiUtils.ts
[expandable.spec.tsx]
***

View file

@ -109,7 +109,7 @@ body.dark-mode .CodeMirror span.cm-type {
}
.CodeMirror-cursor {
border-left: 1px solid #bebebe;
border-left: 1px solid var(--vscode-editor-foreground) !important;
}
.CodeMirror div.CodeMirror-selected {
@ -122,8 +122,11 @@ body.dark-mode .CodeMirror span.cm-type {
border-right: none;
}
.CodeMirror .CodeMirror-gutter-elt {
background-color: var(--vscode-editorGutter-background);
}
.CodeMirror .CodeMirror-gutterwrapper {
background: var(--vscode-editor-background);
border-right: 1px solid var(--vscode-editorGroup-border);
color: var(--vscode-editorLineNumber-foreground);
}

View file

@ -18,6 +18,7 @@ import './codeMirrorWrapper.css';
import * as React from 'react';
import type { CodeMirror } from './codeMirrorModule';
import { ansi2htmlMarkup } from './errorMessage';
import { useMeasure } from '../uiUtils';
export type SourceHighlight = {
line: number;
@ -51,7 +52,7 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
wrapLines,
onChange,
}) => {
const codemirrorElement = React.useRef<HTMLDivElement>(null);
const [measure, codemirrorElement] = useMeasure<HTMLDivElement>();
const [modulePromise] = React.useState<Promise<CodeMirror>>(import('./codeMirrorModule').then(m => m.default));
const codemirrorRef = React.useRef<{ cm: CodeMirror.Editor, highlight?: SourceHighlight[], widgets?: CodeMirror.LineWidget[] } | null>(null);
const [codemirror, setCodemirror] = React.useState<CodeMirror.Editor>();
@ -98,6 +99,11 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
})();
}, [modulePromise, codemirror, codemirrorElement, language, lineNumbers, wrapLines, readOnly]);
React.useEffect(() => {
if (codemirrorRef.current)
codemirrorRef.current.cm.setSize(measure.width, measure.height);
}, [measure]);
React.useEffect(() => {
if (!codemirror)
return;
@ -149,9 +155,9 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
codemirrorRef.current!.highlight = highlight;
codemirrorRef.current!.widgets = widgets;
}
if (revealLine && codemirrorRef.current!.cm.lineCount() >= revealLine)
codemirror.scrollIntoView({ line: revealLine - 1, ch: 0 }, 50);
// Line-less locations have line = 0, but they mean to reveal the file.
if (typeof revealLine === 'number' && codemirrorRef.current!.cm.lineCount() >= revealLine)
codemirror.scrollIntoView({ line: Math.max(0, revealLine - 1), ch: 0 }, 50);
}, [codemirror, text, highlight, revealLine, focusOnChange, onChange]);
return <div className='cm-wrapper' ref={codemirrorElement}></div>;

View file

@ -20,6 +20,10 @@
overflow: hidden;
}
.tabbed-pane .toolbar {
background-color: var(--vscode-sideBar-background);
}
.tabbed-pane .tab-content {
display: flex;
flex: auto;
@ -28,7 +32,6 @@
.tabbed-pane-tab {
padding: 2px 10px 0 10px;
margin-right: 4px;
cursor: pointer;
display: flex;
flex: none;

View file

@ -53,7 +53,7 @@ export const TabbedPane: React.FunctionComponent<{
if (tab.component)
return <div key={tab.id} className='tab-content' style={{ display: selectedTab === tab.id ? 'inherit' : 'none' }}>{tab.component}</div>;
if (selectedTab === tab.id)
return <div key={tab.id} className='tab-content'>{tab.component || tab.render!()}</div>;
return <div key={tab.id} className='tab-content'>{tab.render!()}</div>;
})
}
</div>

View file

@ -15,19 +15,34 @@
*/
.toolbar {
position: relative;
display: flex;
box-shadow: var(--box-shadow);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-sideBarTitle-foreground);
min-height: 35px;
align-items: center;
flex: none;
z-index: 2;
padding-right: 4px;
}
.toolbar-linewrap {
.toolbar:after {
content: '';
display: block;
flex: auto;
position: absolute;
pointer-events: none;
top: 0;
bottom: 0;
left: -2px;
right: -2px;
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px;
z-index: 100;
}
.toolbar.no-shadow:after {
box-shadow: none;
}
.toolbar.no-min-height {
min-height: 0;
}
.toolbar input {

View file

@ -17,11 +17,15 @@
import './toolbar.css';
import * as React from 'react';
export interface ToolbarProps {
}
type ToolbarProps = {
noShadow?: boolean;
noMinHeight?: boolean;
};
export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
children
noShadow,
children,
noMinHeight
}) => {
return <div className='toolbar'>{children}</div>;
return <div className={'toolbar' + (noShadow ? ' no-shadow' : '') + (noMinHeight ? ' no-min-height' : '')}>{children}</div>;
};

View file

@ -21,7 +21,6 @@
color: var(--vscode-sideBarTitle-foreground);
background: transparent;
padding: 4px;
margin: 0 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
@ -43,3 +42,7 @@
.toolbar-button.toggled {
color: var(--vscode-notificationLink-foreground);
}
.toolbar-button.toggled .codicon {
font-weight: bold;
}

View file

@ -41,6 +41,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
className={className}
onMouseDown={preventDefault}
onClick={onClick}
onDoubleClick={preventDefault}
title={title}
disabled={!!disabled}>
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
@ -48,4 +49,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
</button>;
};
const preventDefault = (e: any) => e.preventDefault();
const preventDefault = (e: any) => {
e.stopPropagation();
e.preventDefault();
};

View file

@ -17,8 +17,10 @@
import * as React from 'react';
import './xtermWrapper.css';
import type { ITheme, Terminal } from 'xterm';
import type { FitAddon } from 'xterm-addon-fit';
import type { XtermModule } from './xtermModule';
import { isDarkTheme } from '@web/theme';
import { currentTheme, addThemeListener, removeThemeListener } from '@web/theme';
import { useMeasure } from '@web/uiUtils';
export type XtermDataSource = {
pending: (string | Uint8Array)[];
@ -28,12 +30,22 @@ export type XtermDataSource = {
};
export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
source
source,
}) => {
const xtermElement = React.useRef<HTMLDivElement>(null);
const [measure, xtermElement] = useMeasure<HTMLDivElement>();
const [theme, setTheme] = React.useState(currentTheme());
const [modulePromise] = React.useState<Promise<XtermModule>>(import('./xtermModule').then(m => m.default));
const terminal = React.useRef<Terminal | null>(null);
const terminal = React.useRef<{ terminal: Terminal, fitAddon: FitAddon } | null>(null);
React.useEffect(() => {
addThemeListener(setTheme);
return () => removeThemeListener(setTheme);
}, []);
React.useEffect(() => {
const oldSourceWrite = source.write;
const oldSourceClear = source.clear;
(async () => {
// Always load the module first.
const { Terminal, FitAddon } = await modulePromise;
@ -41,15 +53,19 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
if (!element)
return;
if (terminal.current)
const terminalTheme = theme === 'dark-mode' ? darkTheme : lightTheme;
if (terminal.current && terminal.current.terminal.options.theme === terminalTheme)
return;
if (terminal.current)
element.textContent = '';
const newTerminal = new Terminal({
convertEol: true,
fontSize: 13,
scrollback: 10000,
fontFamily: 'var(--vscode-editor-font-family)',
theme: isDarkTheme() ? darkTheme : lightTheme
theme: terminalTheme,
});
const fitAddon = new FitAddon();
@ -66,16 +82,26 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
};
newTerminal.open(element);
fitAddon.fit();
terminal.current = newTerminal;
const resizeObserver = new ResizeObserver(() => {
source.resize(newTerminal.cols, newTerminal.rows);
fitAddon.fit();
});
resizeObserver.observe(element);
terminal.current = { terminal: newTerminal, fitAddon };
})();
}, [modulePromise, terminal, xtermElement, source]);
return <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}>
</div>;
return () => {
source.clear = oldSourceClear;
source.write = oldSourceWrite;
};
}, [modulePromise, terminal, xtermElement, source, theme]);
React.useEffect(() => {
// Fit reads data from the terminal itself, which updates lazily, probably on some timer
// or mutation observer. Work around it.
setTimeout(() => {
if (!terminal.current)
return;
terminal.current.fitAddon.fit();
source.resize(terminal.current.terminal.cols, terminal.current.terminal.rows);
}, 250);
}, [measure, source]);
return <div data-testid='output' className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}></div>;
};
const lightTheme: ITheme = {

View file

@ -34,9 +34,12 @@ export function applyTheme() {
document.body.classList.add('dark-mode');
}
type Theme = 'dark-mode' | 'light-mode';
const listeners = new Set<(theme: Theme) => void>();
export function toggleTheme() {
const oldTheme = settings.getString('theme', 'light-mode');
let newTheme: string;
let newTheme: Theme;
if (oldTheme === 'dark-mode')
newTheme = 'light-mode';
else
@ -46,8 +49,18 @@ export function toggleTheme() {
document.body.classList.remove(oldTheme);
document.body.classList.add(newTheme);
settings.setString('theme', newTheme);
for (const listener of listeners)
listener(newTheme);
}
export function isDarkTheme() {
return document.body.classList.contains('dark-mode');
export function addThemeListener(listener: (theme: 'light-mode' | 'dark-mode') => void) {
listeners.add(listener);
}
export function removeThemeListener(listener: (theme: Theme) => void) {
listeners.delete(listener);
}
export function currentTheme(): Theme {
return document.body.classList.contains('dark-mode') ? 'dark-mode' : 'light-mode';
}

View file

@ -16,6 +16,44 @@
import React from 'react';
// Recalculates the value when dependencies change.
export function useAsyncMemo<T>(fn: () => Promise<T>, deps: React.DependencyList, initialValue: T, resetValue?: T) {
const [value, setValue] = React.useState<T>(initialValue);
React.useEffect(() => {
let canceled = false;
if (resetValue !== undefined)
setValue(resetValue);
fn().then(value => {
if (!canceled)
setValue(value);
});
return () => {
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return value;
}
// Tracks the element size and returns it's contentRect (always has x=0, y=0).
export function useMeasure<T extends Element>() {
const ref = React.useRef<T | null>(null);
const [measure, setMeasure] = React.useState(new DOMRect(0, 0, 10, 10));
React.useLayoutEffect(() => {
const target = ref.current;
if (!target)
return;
const resizeObserver = new ResizeObserver((entries: any) => {
const entry = entries[entries.length - 1];
if (entry && entry.contentRect)
setMeasure(entry.contentRect);
});
resizeObserver.observe(target);
return () => resizeObserver.disconnect();
}, [ref]);
return [measure, ref] as const;
}
export function msToString(ms: number): string {
if (!isFinite(ms))
return '-';

BIN
tests/assets/trace-1.31.zip Normal file

Binary file not shown.

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Fixtures, Frame, Locator, Page, Browser, BrowserContext } from '@playwright/test';
import type { Fixtures, FrameLocator, Locator, Page, Browser, BrowserContext } from '@playwright/test';
import { showTraceViewer } from '../../packages/playwright-core/lib/server';
type BaseTestFixtures = {
@ -51,7 +51,7 @@ class TraceViewerPage {
this.consoleStacks = page.locator('.console-stack');
this.stackFrames = page.getByTestId('stack-trace').locator('.list-view-entry');
this.networkRequests = page.locator('.network-request-title');
this.snapshotContainer = page.locator('.snapshot-container iframe');
this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]');
}
async actionIconsText(action: string) {
@ -96,15 +96,11 @@ class TraceViewerPage {
return result.sort();
}
async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<Frame> {
const existing = this.page.mainFrame().childFrames()[0];
await Promise.all([
existing ? existing.waitForNavigation() as any : Promise.resolve(),
this.selectAction(actionName, ordinal),
]);
while (this.page.frames().length < (hasSubframe ? 3 : 2))
async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<FrameLocator> {
await this.selectAction(actionName, ordinal);
while (this.page.frames().length < (hasSubframe ? 4 : 3))
await this.page.waitForEvent('frameattached');
return this.page.mainFrame().childFrames()[0];
return this.page.frameLocator('iframe.snapshot-visible[name=snapshot]');
}
}

View file

@ -41,7 +41,7 @@ it.describe('snapshots', () => {
await snapshotter.reset();
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
const html2 = snapshot2.render().html;
expect(html2.replace(`"call@2"`, `"call@1"`)).toEqual(html1);
expect(html2.replace(/call@2/g, `call@1`)).toEqual(html1);
});
it('should capture resources', async ({ page, toImpl, server, snapshotter }) => {

View file

@ -260,7 +260,7 @@ test('should capture iframe with sandbox attribute', async ({ page, server, runA
// Render snapshot, check expectations.
const snapshotFrame = await traceViewer.snapshotFrame('page.evaluate', 0, true);
const button = await snapshotFrame.childFrames()[0].waitForSelector('button');
const button = snapshotFrame.frameLocator('iframe').locator('button');
expect(await button.textContent()).toBe('Hello iframe');
});
@ -283,8 +283,8 @@ test('should capture data-url svg iframe', async ({ page, server, runAndTrace })
// Render snapshot, check expectations.
const snapshotFrame = await traceViewer.snapshotFrame('page.evaluate', 0, true);
await expect(snapshotFrame.childFrames()[0].locator('svg')).toBeVisible();
const content = await snapshotFrame.childFrames()[0].content();
await expect(snapshotFrame.frameLocator('iframe').locator('svg')).toBeVisible();
const content = await snapshotFrame.frameLocator('iframe').locator(':root').innerHTML();
expect(content).toContain(`d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"`);
});
@ -313,19 +313,9 @@ test('should contain adopted style sheets', async ({ page, runAndTrace, browserN
});
const frame = await traceViewer.snapshotFrame('page.evaluate');
await frame.waitForSelector('button');
const buttonColor = await frame.$eval('button', button => {
return window.getComputedStyle(button).color;
});
expect(buttonColor).toBe('rgb(255, 0, 0)');
const divColor = await frame.$eval('div', div => {
return window.getComputedStyle(div).color;
});
expect(divColor).toBe('rgb(0, 0, 255)');
const spanColor = await frame.$eval('span', span => {
return window.getComputedStyle(span).color;
});
expect(spanColor).toBe('rgb(0, 0, 255)');
await expect(frame.locator('button')).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(frame.locator('div')).toHaveCSS('color', 'rgb(0, 0, 255)');
await expect(frame.locator('span')).toHaveCSS('color', 'rgb(0, 0, 255)');
});
test('should work with adopted style sheets and replace/replaceSync', async ({ page, runAndTrace, browserName }) => {
@ -350,27 +340,15 @@ test('should work with adopted style sheets and replace/replaceSync', async ({ p
{
const frame = await traceViewer.snapshotFrame('page.evaluate', 0);
await frame.waitForSelector('button');
const buttonColor = await frame.$eval('button', button => {
return window.getComputedStyle(button).color;
});
expect(buttonColor).toBe('rgb(255, 0, 0)');
await expect(frame.locator('button')).toHaveCSS('color', 'rgb(255, 0, 0)');
}
{
const frame = await traceViewer.snapshotFrame('page.evaluate', 1);
await frame.waitForSelector('button');
const buttonColor = await frame.$eval('button', button => {
return window.getComputedStyle(button).color;
});
expect(buttonColor).toBe('rgb(0, 0, 255)');
await expect(frame.locator('button')).toHaveCSS('color', 'rgb(0, 0, 255)');
}
{
const frame = await traceViewer.snapshotFrame('page.evaluate', 2);
await frame.waitForSelector('button');
const buttonColor = await frame.$eval('button', button => {
return window.getComputedStyle(button).color;
});
expect(buttonColor).toBe('rgb(0, 255, 0)');
await expect(frame.locator('button')).toHaveCSS('color', 'rgb(0, 255, 0)');
}
});
@ -402,8 +380,7 @@ test('should restore scroll positions', async ({ page, runAndTrace, browserName
// Render snapshot, check expectations.
const frame = await traceViewer.snapshotFrame('scrollIntoViewIfNeeded');
const div = await frame.waitForSelector('div');
expect(await div.evaluate(div => div.scrollTop)).toBe(136);
expect(await frame.locator('div').evaluate(div => div.scrollTop)).toBe(136);
});
test('should restore control values', async ({ page, runAndTrace }) => {
@ -450,13 +427,10 @@ test('should restore control values', async ({ page, runAndTrace }) => {
await expect(textarea).toHaveText('old');
await expect(textarea).toHaveValue('hello');
expect(await frame.$eval('option >> nth=0', o => o.hasAttribute('selected'))).toBe(false);
expect(await frame.$eval('option >> nth=1', o => o.hasAttribute('selected'))).toBe(true);
expect(await frame.$eval('option >> nth=2', o => o.hasAttribute('selected'))).toBe(false);
expect(await frame.locator('select').evaluate(s => {
const options = [...(s as HTMLSelectElement).selectedOptions];
return options.map(option => option.value);
})).toEqual(['opt1', 'opt3']);
expect(await frame.locator('option >> nth=0').evaluate(o => o.hasAttribute('selected'))).toBe(false);
expect(await frame.locator('option >> nth=1').evaluate(o => o.hasAttribute('selected'))).toBe(true);
expect(await frame.locator('option >> nth=2').evaluate(o => o.hasAttribute('selected'))).toBe(false);
await expect(frame.locator('select')).toHaveValues(['opt1', 'opt3']);
});
test('should work with meta CSP', async ({ page, runAndTrace, browserName }) => {
@ -479,9 +453,8 @@ test('should work with meta CSP', async ({ page, runAndTrace, browserName }) =>
// Render snapshot, check expectations.
const frame = await traceViewer.snapshotFrame('$eval');
await frame.waitForSelector('div');
// Should render shadow dom with post-processing script.
expect(await frame.textContent('span')).toBe('World');
await expect(frame.locator('span')).toHaveText('World');
});
test('should handle multiple headers', async ({ page, server, runAndTrace, browserName }) => {
@ -497,9 +470,8 @@ test('should handle multiple headers', async ({ page, server, runAndTrace, brows
});
const frame = await traceViewer.snapshotFrame('setContent');
await frame.waitForSelector('div');
const padding = await frame.$eval('body', body => window.getComputedStyle(body).paddingLeft);
expect(padding).toBe('42px');
await frame.locator('div').waitFor();
await expect(frame.locator('body')).toHaveCSS('padding-left', '42px');
});
test('should handle src=blob', async ({ page, server, runAndTrace, browserName }) => {
@ -521,14 +493,12 @@ test('should handle src=blob', async ({ page, server, runAndTrace, browserName }
});
const frame = await traceViewer.snapshotFrame('page.evaluate');
const img = await frame.waitForSelector('img');
const size = await img.evaluate(e => (e as HTMLImageElement).naturalWidth);
const size = await frame.locator('img').evaluate(e => (e as HTMLImageElement).naturalWidth);
expect(size).toBe(10);
});
test('should register custom elements', async ({ page, server, runAndTrace }) => {
const traceViewer = await runAndTrace(async () => {
page.on('console', console.log);
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => {
customElements.define('my-element', class extends HTMLElement {
@ -719,6 +689,19 @@ test('should include requestUrl in route.fulfill', async ({ page, runAndTrace, b
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
});
test('should not crash with broken locator', async ({ page, runAndTrace, server }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/21832' });
const traceViewer = await runAndTrace(async () => {
try {
await page.locator('[class*=github-btn] a]').click();
} catch (e) {
}
});
await expect(traceViewer.page).toHaveTitle('Playwright Trace Viewer');
const header = traceViewer.page.getByText('Playwright', { exact: true });
await expect(header).toBeVisible();
});
test('should include requestUrl in route.continue', async ({ page, runAndTrace, server }) => {
await page.route('**/*', route => {
route.continue({ url: server.EMPTY_PAGE });
@ -767,8 +750,7 @@ test('should serve overridden request', async ({ page, runAndTrace, server }) =>
});
// Render snapshot, check expectations.
const snapshotFrame = await traceViewer.snapshotFrame('page.goto');
const color = await snapshotFrame.locator('body').evaluate(body => getComputedStyle(body).backgroundColor);
expect(color).toBe('rgb(255, 0, 0)');
await expect(snapshotFrame.locator('body')).toHaveCSS('background-color', 'rgb(255, 0, 0)');
});
test('should display waitForLoadState even if did not wait for it', async ({ runAndTrace, server, page }) => {
@ -803,7 +785,7 @@ test('should pick locator', async ({ page, runAndTrace, server }) => {
});
const snapshot = await traceViewer.snapshotFrame('page.setContent');
await traceViewer.page.getByTitle('Pick locator').click();
await snapshot.click('button');
await snapshot.locator('button').click();
await expect(traceViewer.page.locator('.cm-wrapper')).toContainText(`getByRole('button', { name: 'Submit' })`);
});
@ -818,3 +800,9 @@ test('should update highlight when typing', async ({ page, runAndTrace, server }
await traceViewer.page.keyboard.type('button');
await expect(snapshot.locator('x-pw-glass')).toBeVisible();
});
test('should open trace-1.31', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer([path.join(__dirname, '../assets/trace-1.31.zip')]);
const snapshot = await traceViewer.snapshotFrame('locator.click');
await expect(snapshot.locator('[__playwright_target__]')).toHaveText(['Submit']);
});

View file

@ -75,32 +75,3 @@ test('should treat enums equally', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should allow declare fields', async ({ runInlineTest }) => {
const result = await runInlineTest({
'example.spec.ts': `
import { test, expect } from '@playwright/test';
class Base {
constructor(p1, p2) {
this.p1 = p1;
this.p2 = p2;
}
}
class Derived extends Base {
p1: string;
declare p2: string;
}
test('works', () => {
const d = new Derived('value1', 'value2');
expect(d.p1).toBe(undefined);
expect(d.p2).toBe('value2');
})
`,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});

View file

@ -163,7 +163,6 @@ test('should exit with code 0 with --pass-with-no-tests', async ({ runInlineTest
`,
}, undefined, undefined, { additionalArgs: ['--pass-with-no-tests'] });
expect(result.exitCode).toBe(0);
expect(result.output).toContain(`Running 0 tests using 0 workers`);
});
test('should exit with code 1 when config is not found', async ({ runInlineTest }) => {

View file

@ -35,17 +35,10 @@ test('should retry predicate', async ({ runInlineTest }) => {
}).toPass();
expect(i).toBe(3);
});
test('should retry expect.soft assertions', async () => {
let i = 0;
await test.expect(() => {
expect.soft(++i).toBe(3);
}).toPass();
expect(i).toBe(3);
});
`
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(3);
expect(result.passed).toBe(2);
});
test('should respect timeout', async ({ runInlineTest }) => {
@ -159,74 +152,12 @@ test('should use custom message', async ({ runInlineTest }) => {
expect(result.failed).toBe(1);
});
test('should swallow all soft errors inside toPass matcher, if successful', async ({ runInlineTest }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/20437' });
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('should respect soft', async () => {
expect.soft('before-toPass').toBe('zzzz');
let i = 0;
await test.expect(() => {
++i;
expect.soft('inside-toPass-' + i).toBe('inside-toPass-2');
}).toPass({ timeout: 1000 });
expect.soft('after-toPass').toBe('zzzz');
});
`
});
expect(result.output).toContain('Received: "before-toPass"');
expect(result.output).toContain('Received: "after-toPass"');
expect(result.output).not.toContain('Received: "inside-toPass-1"');
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
});
test('should work with no.toPass and failing soft assertion', async ({ runInlineTest }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/20518' });
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('should work', async () => {
await test.expect(() => {
expect.soft(1).toBe(2);
}).not.toPass({ timeout: 1000 });
});
`
});
expect(result.exitCode).toBe(0);
expect(result.failed).toBe(0);
expect(result.passed).toBe(1);
});
test('should show only soft errors on last toPass pass', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('should respect soft', async () => {
let i = 0;
await test.expect(() => {
++i;
expect.soft('inside-toPass-' + i).toBe('0');
}).toPass({ timeout: 1000, intervals: [100, 100, 100000] });
});
`
});
expect(result.output).not.toContain('Received: "inside-toPass-1"');
expect(result.output).not.toContain('Received: "inside-toPass-2"');
expect(result.output).toContain('Received: "inside-toPass-3"');
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
});
test('should work with soft', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('should respect soft', async () => {
await test.expect.soft(() => {
await expect.soft(() => {
expect(1).toBe(3);
}).toPass({ timeout: 1000 });
expect.soft(2).toBe(3);

View file

@ -0,0 +1,63 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 as baseTest, expect } from './playwright-test-fixtures';
import { RunServer } from '../config/remoteServer';
import type { PlaywrightServer } from '../config/remoteServer';
const test = baseTest.extend<{ runServer: () => Promise<PlaywrightServer> }>({
runServer: async ({ childProcess }, use) => {
let server: PlaywrightServer | undefined;
await use(async () => {
const runServer = new RunServer();
await runServer._start(childProcess);
server = runServer;
return server;
});
if (server) {
await server.close();
// Give any connected browsers a chance to disconnect to avoid
// poisoning next test with quasy-alive browsers.
await new Promise(f => setTimeout(f, 1000));
}
},
});
test('should reuse browser', async ({ runInlineTest, runServer }) => {
const server = await runServer();
const result = await runInlineTest({
'src/a.test.ts': `
import { test, expect } from '@playwright/test';
test('a', async ({ browser }) => {
console.log('%%' + process.env.TEST_WORKER_INDEX + ':' + browser._guid);
});
`,
'src/b.test.ts': `
import { test, expect } from '@playwright/test';
test('b', async ({ browser }) => {
console.log('%%' + process.env.TEST_WORKER_INDEX + ':' + browser._guid);
});
`,
}, { workers: 2 }, { PW_TEST_REUSE_CONTEXT: '1', PW_TEST_CONNECT_WS_ENDPOINT: server.wsEndpoint() });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(2);
expect(result.outputLines).toHaveLength(2);
const [workerIndex1, guid1] = result.outputLines[0].split(':');
const [workerIndex2, guid2] = result.outputLines[1].split(':');
expect(guid2).toBe(guid1);
expect(workerIndex2).not.toBe(workerIndex1);
});

View file

@ -132,6 +132,42 @@ test('should not throw with trace: on-first-retry and two retries in the same wo
expect(result.flaky).toBe(6);
});
test('should not mixup network files between contexts', async ({ runInlineTest, server }, testInfo) => {
// NOTE: this test reproduces the issue 10% of the time. Running with --repeat-each=20 helps.
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22089' });
const result = await runInlineTest({
'playwright.config.ts': `
export default { use: { trace: 'on' } };
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
let page1, page2;
test.beforeAll(async ({ browser }) => {
page1 = await browser.newPage();
await page1.goto("${server.EMPTY_PAGE}");
page2 = await browser.newPage();
await page2.goto("${server.EMPTY_PAGE}");
});
test.afterAll(async () => {
await page1.close();
await page2.close();
});
test('example', async ({ page }) => {
await page.goto("${server.EMPTY_PAGE}");
});
`,
}, { 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);
});
test('should save sources when requested', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': `
@ -286,3 +322,21 @@ test('should respect --trace', async ({ runInlineTest }, testInfo) => {
expect(result.passed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy();
});
test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': `
export default { use: { trace: 'on' } };
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test 1', async ({ page }) => {
await page.goto('about:blank');
});
`,
}, {}, { PW_TEST_DISABLE_TRACING: '1' });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBe(false);
});

View file

@ -21,13 +21,21 @@ import type { TestChildProcess } from '../config/commonFixtures';
import { cleanEnv, cliEntrypoint, removeFolderAsync, test as base, writeFiles } from './playwright-test-fixtures';
import type { Files, RunOptions } from './playwright-test-fixtures';
import type { Browser, Page, TestInfo } from './stable-test-runner';
import { createGuid } from '../../packages/playwright-core/src/utils/crypto';
type Latch = {
blockingCode: string;
open: () => void;
close: () => void;
};
type Fixtures = {
runUITest: (files: Files, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<Page>;
createLatch: () => Latch;
};
export function dumpTestTree(page: Page): () => Promise<string> {
return () => page.getByTestId('test-tree').evaluate(async treeElement => {
export function dumpTestTree(page: Page, options: { time?: boolean } = {}): () => Promise<string> {
return () => page.getByTestId('test-tree').evaluate(async (treeElement, options) => {
function iconName(iconElement: Element): string {
const icon = iconElement.className.replace('codicon codicon-', '');
if (icon === 'chevron-right')
@ -48,6 +56,8 @@ export function dumpTestTree(page: Page): () => Promise<string> {
return '👁';
if (icon === 'loading')
return '↻';
if (icon === 'clock')
return '🕦';
return icon;
}
@ -60,23 +70,27 @@ export function dumpTestTree(page: Page): () => Promise<string> {
const indent = listItem.querySelectorAll('.list-view-indent').length;
const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : '';
const selected = listItem.classList.contains('selected') ? ' <=' : '';
result.push(' ' + ' '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + listItem.textContent + watch + selected);
const title = listItem.querySelector('.watch-mode-list-item-title').textContent;
const timeElement = options.time ? listItem.querySelector('.watch-mode-list-item-time') : undefined;
const time = timeElement ? ' ' + timeElement.textContent.replace(/\d+m?s/, 'XXms') : '';
result.push(' ' + ' '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + title + time + watch + selected);
}
return '\n' + result.join('\n') + '\n ';
});
}, options);
}
export const test = base
.extend<Fixtures>({
runUITest: async ({ childProcess, playwright, headless }, use, testInfo: TestInfo) => {
testInfo.slow();
if (process.env.CI)
testInfo.slow();
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-'));
let testProcess: TestChildProcess | undefined;
let browser: Browser | undefined;
await use(async (files: Files, env: NodeJS.ProcessEnv = {}, options: RunOptions = {}) => {
const baseDir = await writeFiles(testInfo, files, true);
testProcess = childProcess({
command: ['node', cliEntrypoint, 'ui', '--workers=1', ...(options.additionalArgs || [])],
command: ['node', cliEntrypoint, 'test', '--ui', '--workers=1', ...(options.additionalArgs || [])],
env: {
...cleanEnv(env),
PWTEST_UNDER_TEST: '1',
@ -98,6 +112,22 @@ export const test = base
await testProcess?.close();
await removeFolderAsync(cacheDir);
},
createLatch: async ({}, use, testInfo) => {
await use(() => {
const latchFile = path.join(testInfo.project.outputDir, createGuid() + '.latch');
return {
blockingCode: `await ((${waitForLatch})(${JSON.stringify(latchFile)}))`,
open: () => fs.writeFileSync(latchFile, 'ok'),
close: () => fs.unlinkSync(latchFile),
};
});
},
});
export { expect } from './stable-test-runner';
async function waitForLatch(latchFile: string) {
const fs = require('fs');
while (!fs.existsSync(latchFile))
await new Promise(f => setTimeout(f, 250));
}

View file

@ -148,3 +148,58 @@ test('should filter by project', async ({ runUITest }) => {
await expect(page.getByText('Projects: foo bar')).toBeVisible();
});
test('should not hide filtered while running', async ({ runUITest, createLatch }) => {
const latch = createLatch();
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
test('fails', async () => {
${latch.blockingCode}
expect(1).toBe(2);
});
`,
});
await page.getByTitle('Run all').click();
latch.open();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails <=
`);
latch.close();
await page.getByText('Status:').click();
await page.getByLabel('failed').setChecked(true);
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
fails <=
`);
});
test('should filter skipped', async ({ runUITest, createLatch }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
test.skip('fails', async () => {
expect(1).toBe(2);
});
`,
});
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails
`);
await page.getByText('Status:').click();
await page.getByLabel('skipped').setChecked(true);
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
fails
`);
});

View file

@ -0,0 +1,58 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 './ui-mode-fixtures';
test.describe.configure({ mode: 'parallel' });
test('should print load errors', async ({ runUITest }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('syntax error', () => {
await 1;
});
`,
});
await page.getByTitle('Toggle output').click();
await expect(page.getByTestId('output')).toContainText(`Unexpected reserved word 'await'`);
});
test('should work after theme switch', async ({ runUITest, writeFiles }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('syntax error', async () => {
console.log('Hello world 1');
});
`,
});
await page.getByTitle('Toggle output').click();
await page.getByTitle('Run all').click();
await expect(page.getByTestId('output')).toContainText(`Hello world 1`);
await page.getByTitle('Toggle color mode').click();
writeFiles({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('syntax error', async () => {
console.log('Hello world 2');
});
`,
});
await page.getByTitle('Run all').click();
await expect(page.getByTestId('output')).toContainText(`Hello world 2`);
});

View file

@ -73,9 +73,9 @@ test('should update trace live', async ({ runUITest, server }) => {
onePromise.resolve();
await expect(
page.frameLocator('id=snapshot').locator('body'),
page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('body'),
'verify snapshot'
).toHaveText('One');
).toHaveText('One', { timeout: 15000 });
await expect(listItem).toHaveText([
/browserContext.newPage[\d.]+m?s/,
/page.gotohttp:\/\/localhost:\d+\/one.html[\d.]+m?s/,
@ -99,7 +99,7 @@ test('should update trace live', async ({ runUITest, server }) => {
twoPromise.resolve();
await expect(
page.frameLocator('id=snapshot').locator('body'),
page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('body'),
'verify snapshot'
).toHaveText('Two');

View file

@ -59,6 +59,27 @@ test('should run visible', async ({ runUITest }) => {
passes
skipped
`);
await expect(page.getByTestId('status-line')).toHaveText('4/8 passed (50%)');
});
test('should show running progress', async ({ runUITest }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test 1', async () => {});
test('test 2', async () => new Promise(() => {}));
test('test 3', async () => {});
test('test 4', async () => {});
`,
});
await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)', { timeout: 15000 });
await page.getByTitle('Stop').click();
await expect(page.getByTestId('status-line')).toHaveText('1/4 passed (25%)', { timeout: 15000 });
await page.getByTitle('Reload').click();
await expect(page.getByTestId('status-line')).toBeHidden();
});
test('should run on hover', async ({ runUITest }) => {
@ -207,7 +228,7 @@ test('should stop', async ({ runUITest }) => {
`,
});
await expect(page.getByTitle('Run all')).toBeEnabled();
await expect(page.getByTitle('Run all')).toBeEnabled({ timeout: 15000 });
await expect(page.getByTitle('Stop')).toBeDisabled();
await page.getByTitle('Run all').click();
@ -217,7 +238,7 @@ test('should stop', async ({ runUITest }) => {
test 0
test 1
test 2
test 3
🕦 test 3
`);
await expect(page.getByTitle('Run all')).toBeDisabled();
@ -233,3 +254,55 @@ test('should stop', async ({ runUITest }) => {
test 3
`);
});
test('should run folder', async ({ runUITest }) => {
const page = await runUITest({
'a/folder-b/folder-c/inC.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
'a/folder-b/in-b.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
'a/in-a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
});
await page.getByText('folder-b').hover();
await page.getByRole('listitem').filter({ hasText: 'folder-b' }).getByTitle('Run').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
folder-b <=
folder-c
in-b.test.ts
in-a.test.ts
passes
`);
});
test('should show time', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
`);
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page, { time: true }), { timeout: 15000 }).toBe(`
a.test.ts
passes XXms
fails XXms <=
suite
b.test.ts
passes XXms
fails XXms
c.test.ts
passes XXms
skipped
`);
await expect(page.getByTestId('status-line')).toHaveText('4/8 passed (50%)');
});

View file

@ -0,0 +1,66 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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, dumpTestTree } from './ui-mode-fixtures';
test.describe.configure({ mode: 'parallel' });
const basicTestTree = {
'a.test.ts': `
import { test } from '@playwright/test';
test('first', () => {});
test('second', () => {});
`,
'b.test.ts': `
import { test } from '@playwright/test';
test('third', () => {});
`,
};
test('should show selected test in sources', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
first
second
b.test.ts
third
`);
await page.getByTestId('test-tree').getByText('first').click();
await expect(
page.getByTestId('source-code').locator('.source-tab-file-name')
).toHaveText('a.test.ts');
await expect(
page.locator('.CodeMirror .source-line-running'),
).toHaveText(`3 test('first', () => {});`);
await page.getByTestId('test-tree').getByText('second').click();
await expect(
page.getByTestId('source-code').locator('.source-tab-file-name')
).toHaveText('a.test.ts');
await expect(
page.locator('.CodeMirror .source-line-running'),
).toHaveText(`4 test('second', () => {});`);
await page.getByTestId('test-tree').getByText('third').click();
await expect(
page.getByTestId('source-code').locator('.source-tab-file-name')
).toHaveText('b.test.ts');
await expect(
page.locator('.CodeMirror .source-line-running'),
).toHaveText(`3 test('third', () => {});`);
});

View file

@ -85,7 +85,7 @@ test('should traverse up/down', async ({ runUITest }) => {
test('should expand / collapse groups', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await page.getByText('suite').click();
await page.getByTestId('test-tree').getByText('suite').click();
await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
@ -104,7 +104,7 @@ test('should expand / collapse groups', async ({ runUITest }) => {
suite <=
`);
await page.getByText('passes').first().click();
await page.getByTestId('test-tree').getByText('passes').first().click();
await page.keyboard.press('ArrowLeft');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts <=
@ -117,3 +117,108 @@ test('should expand / collapse groups', async ({ runUITest }) => {
a.test.ts <=
`);
});
test('should merge folder trees', async ({ runUITest }) => {
const page = await runUITest({
'a/b/c/inC.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
'a/b/in-b.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
'a/in-a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
b
c
in-b.test.ts
in-a.test.ts
passes
`);
});
test('should list parametrized tests', async ({ runUITest }) => {
const page = await runUITest({
'a.test.ts': `
import { test } from '@playwright/test';
test.describe('cookies', () => {
for (const country of ['FR', 'DE', 'LT']) {
test.describe(() => {
test('test ' + country, async ({}) => {});
});
}
})
`
});
await page.getByText('cookies').click();
await page.keyboard.press('ArrowRight');
await page.getByText('<anonymous>').click();
await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
cookies
<anonymous> <=
test FR
test DE
test LT
`);
});
test('should update parametrized tests', async ({ runUITest, writeFiles }) => {
const page = await runUITest({
'a.test.ts': `
import { test } from '@playwright/test';
test.describe('cookies', () => {
for (const country of ['FR', 'DE', 'LT']) {
test.describe(() => {
test('test ' + country, async ({}) => {});
});
}
})
`
});
await page.getByText('cookies').click();
await page.keyboard.press('ArrowRight');
await page.getByText('<anonymous>').click();
await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
cookies
<anonymous> <=
test FR
test DE
test LT
`);
writeFiles({
'a.test.ts': `
import { test } from '@playwright/test';
test.describe('cookies', () => {
for (const country of ['FR', 'LT']) {
test.describe(() => {
test('test ' + country, async ({}) => {});
});
}
})
`
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
cookies
<anonymous> <=
test FR
test LT
`);
});

View file

@ -168,3 +168,62 @@ test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, d
inner fails
`);
});
test('should update test locations', async ({ runUITest, writeFiles, deleteFile }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes
`);
const messages: any = [];
await page.exposeBinding('_overrideProtocolForTest', (_, data) => messages.push(data));
const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' });
await passesItemLocator.hover();
await passesItemLocator.getByTitle('Open in VS Code').click();
expect(messages).toEqual([{
method: 'open',
params: {
location: expect.stringContaining('a.test.ts:3'),
},
}]);
await writeFiles({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('new-test', () => {});
test('passes', () => {});
`
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
new-test
passes <=
`);
messages.length = 0;
await passesItemLocator.hover();
await passesItemLocator.getByTitle('Open in VS Code').click();
expect(messages).toEqual([{
method: 'open',
params: {
location: expect.stringContaining('a.test.ts:5'),
},
}]);
await expect(
page.getByTestId('source-code').locator('.source-tab-file-name')
).toHaveText('a.test.ts');
await expect(page.locator('.CodeMirror-code')).toContainText(`3 test('new-test', () => {});`);
});

View file

@ -29,6 +29,12 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
await page.getByText('fails').click();
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Watch').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails 👁 <=
`);
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Run').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
@ -51,3 +57,208 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
fails 👁 <=
`);
});
test('should watch e2e deps', async ({ runUITest, writeFiles }) => {
const page = await runUITest({
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';
export default defineConfig({ testDir: 'tests' });
`,
'src/helper.ts': `
export const answer = 41;
`,
'tests/a.test.ts': `
import { test, expect } from '@playwright/test';
import { answer } from '../src/helper';
test('answer', () => { expect(answer).toBe(42); });
`,
});
await page.getByText('answer').click();
await page.getByRole('listitem').filter({ hasText: 'answer' }).getByTitle('Watch').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
answer 👁 <=
`);
await writeFiles({
'src/helper.ts': `
export const answer = 42;
`
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
answer 👁 <=
`);
});
test('should batch watch updates', async ({ runUITest, writeFiles }) => {
const page = await runUITest({
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
});
await page.getByText('a.test.ts').click();
await page.getByRole('listitem').filter({ hasText: 'a.test.ts' }).getByTitle('Watch').click();
await page.getByText('b.test.ts').click();
await page.getByRole('listitem').filter({ hasText: 'b.test.ts' }).getByTitle('Watch').click();
await page.getByText('c.test.ts').click();
await page.getByRole('listitem').filter({ hasText: 'c.test.ts' }).getByTitle('Watch').click();
await page.getByText('d.test.ts').click();
await page.getByRole('listitem').filter({ hasText: 'd.test.ts' }).getByTitle('Watch').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts 👁
test
b.test.ts 👁
test
c.test.ts 👁
test
d.test.ts 👁 <=
test
`);
await writeFiles({
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
});
await expect(page.getByTestId('status-line')).toHaveText('4/4 passed (100%)', { timeout: 15000 });
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts 👁
test
b.test.ts 👁
test
c.test.ts 👁
test
d.test.ts 👁 <=
test
`);
});
test('should watch all', async ({ runUITest, writeFiles }) => {
const page = await runUITest({
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
test
b.test.ts
test
c.test.ts
test
d.test.ts
test
`);
await page.getByTitle('Watch all').click();
await writeFiles({
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
});
await expect(page.getByTestId('status-line')).toHaveText('2/2 passed (100%)', { timeout: 15000 });
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
test
b.test.ts
test
c.test.ts
test
d.test.ts
test
`);
});
test('should watch new file', async ({ runUITest, writeFiles }) => {
const page = await runUITest({
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
});
await page.getByTitle('Watch all').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
test
`);
// First time add file.
await writeFiles({
'b.test.ts': ` import { test } from '@playwright/test'; test('test', () => {});`,
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
test
b.test.ts
test
`);
// Second time run file.
await writeFiles({
'b.test.ts': ` import { test } from '@playwright/test'; test('test', () => {});`,
});
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)', { timeout: 15000 });
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
test
b.test.ts
test
`);
});
test('should queue watches', async ({ runUITest, writeFiles, createLatch }) => {
const latch = createLatch();
const page = await runUITest({
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'b.test.ts': `import { test } from '@playwright/test'; test('test', async () => {
${latch.blockingCode}
});`,
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
test
b.test.ts
test
c.test.ts
test
d.test.ts
test
`);
await page.getByTitle('Watch all').click();
await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)', { timeout: 15000 });
await writeFiles({
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
});
// Now watches should not kick in.
await new Promise(f => setTimeout(f, 1000));
await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)', { timeout: 15000 });
// Allow test to finish and new watch to kick in.
latch.open();
await expect(page.getByTestId('status-line')).toHaveText('3/3 passed (100%)', { timeout: 15000 });
});

View file

@ -0,0 +1,95 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 './ui-mode-fixtures';
test.describe.configure({ mode: 'parallel' });
test('should merge trace events', async ({ runUITest, server }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('trace test', async ({ page }) => {
await page.setContent('<button>Submit</button>');
expect(1).toBe(1);
await page.getByRole('button').click();
expect(2).toBe(2);
});
`,
});
await page.getByText('trace test').dblclick();
const listItem = page.getByTestId('action-list').getByRole('listitem');
await expect(
listItem,
'action list'
).toHaveText([
/browserContext\.newPage[\d.]+m?s/,
/page\.setContent[\d.]+m?s/,
/expect\.toBe[\d.]+m?s/,
/locator\.clickgetByRole\('button'\)[\d.]+m?s/,
/expect\.toBe[\d.]+m?s/,
]);
});
test('should locate sync assertions in source', async ({ runUITest, server }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('trace test', async ({}) => {
expect(1).toBe(1);
});
`,
});
await page.getByText('trace test').dblclick();
await expect(
page.locator('.CodeMirror .source-line-running'),
'check source tab',
).toHaveText('4 expect(1).toBe(1);');
});
test('should show snapshots for sync assertions', async ({ runUITest, server }) => {
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('trace test', async ({ page }) => {
await page.setContent('<button>Submit</button>');
await page.getByRole('button').click();
expect(1).toBe(1);
});
`,
});
await page.getByText('trace test').dblclick();
const listItem = page.getByTestId('action-list').getByRole('listitem');
await expect(
listItem,
'action list'
).toHaveText([
/browserContext\.newPage[\d.]+m?s/,
/page\.setContent[\d.]+m?s/,
/locator\.clickgetByRole\('button'\)[\d.]+m?s/,
/expect\.toBe[\d.]+m?s/,
], { timeout: 15000 });
await expect(
page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('button'),
'verify snapshot'
).toHaveText('Submit');
});

View file

@ -457,6 +457,32 @@ test('should send Accept header', async ({ runInlineTest, server }) => {
expect(acceptHeader).toBe('*/*');
});
test('should follow redirects', async ({ runInlineTest, server }) => {
server.setRedirect('/redirect', '/redirected-to');
server.setRoute('/redirected-to', (req, res) => {
res.end('<html><body>hello</body></html>');
});
const result = await runInlineTest({
'test.spec.ts': `
import { test, expect } from '@playwright/test';
test('connect to the server', async ({baseURL, page}) => {
await page.goto('http://localhost:${server.PORT}/redirect');
expect(await page.textContent('body')).toBe('hello');
});
`,
'playwright.config.ts': `
module.exports = {
webServer: {
command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${server.PORT}',
url: 'http://localhost:${server.PORT}/redirect',
reuseExistingServer: true,
}
};
`,
});
expect(result.exitCode).toBe(0);
});
test('should create multiple servers', async ({ runInlineTest }, { workerIndex }) => {
const port = workerIndex * 2 + 10500;
const result = await runInlineTest({