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' name: 'Playwright Tests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
@ -194,7 +194,7 @@ jobs:
name: 'Playwright Tests' name: 'Playwright Tests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
@ -218,7 +218,7 @@ jobs:
name: 'Playwright Tests' name: 'Playwright Tests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-java@v3 - uses: actions/setup-java@v3
@ -239,7 +239,7 @@ jobs:
name: 'Playwright Tests' name: 'Playwright Tests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup dotnet - name: Setup dotnet
@ -264,7 +264,7 @@ jobs:
name: 'Playwright Tests - ${{ matrix.project }} - Shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }}' name: 'Playwright Tests - ${{ matrix.project }} - Shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }}'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -299,7 +299,7 @@ jobs:
- deployment: Run_E2E_Tests - deployment: Run_E2E_Tests
pool: pool:
vmImage: ubuntu-20.04 vmImage: ubuntu-20.04
container: mcr.microsoft.com/playwright:v1.32.0-focal container: mcr.microsoft.com/playwright:v1.32.3-focal
environment: testing environment: testing
strategy: strategy:
runOnce: runOnce:
@ -325,7 +325,7 @@ jobs:
- deployment: Run_E2E_Tests - deployment: Run_E2E_Tests
pool: pool:
vmImage: ubuntu-20.04 vmImage: ubuntu-20.04
container: mcr.microsoft.com/playwright:v1.32.0-focal container: mcr.microsoft.com/playwright:v1.32.3-focal
environment: testing environment: testing
strategy: strategy:
runOnce: runOnce:
@ -369,7 +369,7 @@ Running Playwright on CircleCI is very similar to running on GitHub Actions. In
executors: executors:
pw-focal-development: pw-focal-development:
docker: 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. 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 ```groovy
pipeline { pipeline {
agent { docker { image 'mcr.microsoft.com/playwright:v1.32.0-focal' } } agent { docker { image 'mcr.microsoft.com/playwright:v1.32.3-focal' } }
stages { stages {
stage('e2e-tests') { stage('e2e-tests') {
steps { 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)). 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 ```yml
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
``` ```
### GitLab CI ### GitLab CI
@ -434,7 +434,7 @@ stages:
tests: tests:
stage: test stage: test
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
script: script:
... ...
``` ```
@ -450,7 +450,7 @@ stages:
tests: tests:
stage: test stage: test
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
parallel: 7 parallel: 7
script: script:
- npm ci - npm ci
@ -465,7 +465,7 @@ stages:
tests: tests:
stage: test stage: test
image: mcr.microsoft.com/playwright:v1.32.0-focal image: mcr.microsoft.com/playwright:v1.32.3-focal
parallel: parallel:
matrix: matrix:
- PROJECT: ['chromium', 'webkit'] - 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 ### Pull the image
```bash js ```bash js
docker pull mcr.microsoft.com/playwright:v1.32.0-focal docker pull mcr.microsoft.com/playwright:v1.32.3-focal
``` ```
```bash python ```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 ```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 ```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 ### 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. 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 ```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 ```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 ```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 ```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 #### 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. 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 ```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 ```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 ```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 ```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: [`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 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 ## Version 1.31
### New APIs ### New APIs

View file

@ -4,6 +4,26 @@ title: "Release notes"
toc_max_heading_level: 2 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 ## Version 1.31
### New APIs ### New APIs

View file

@ -6,6 +6,47 @@ toc_max_heading_level: 2
import LiteYouTube from '@site/src/components/LiteYouTube'; 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 ## Version 1.31
<LiteYouTube <LiteYouTube
@ -433,7 +474,7 @@ This version was also tested against the following stable channels:
### Announcements ### 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 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). * 🪦 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. * ⚠️ 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 ⚠️ ### ⚠️ Breaking Changes ⚠️

View file

@ -4,6 +4,27 @@ title: "Release notes"
toc_max_heading_level: 2 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 ## Version 1.31
### New APIs ### New APIs
@ -237,7 +258,7 @@ This version was also tested against the following stable channels:
### Announcements ### 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). * 🪦 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. * ⚠️ 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]>> - type: ?<[Object]|[Array]<[Object]>>
- `command` <[string]> Shell command to start. For example `npm run start`.. - `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. - `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`. - `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. - `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. - `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
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. In this example the chromium, firefox and webkit projects depend on the setup project.
@ -164,7 +164,7 @@ export default defineConfig({
projects: [ projects: [
{ {
name: 'setup', name: 'setup',
testMatch: /global.setup\.ts/, testMatch: '**/*.setup.ts',
}, },
{ {
name: 'chromium', 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 ## 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). 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: If you are not on the same operating system as your CI system, you can use Docker to generate/update the screenshots:
```bash ```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 npm install
npx playwright test --update-snapshots npx playwright test --update-snapshots
``` ```

72
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

@ -226,6 +226,8 @@ export class PlaywrightConnection {
} }
private async _createOwnedSocksProxy(playwright: Playwright): Promise<SocksProxy | undefined> { private async _createOwnedSocksProxy(playwright: Playwright): Promise<SocksProxy | undefined> {
if (!this._options.socksProxyPattern)
return;
const socksProxy = new SocksProxy(); const socksProxy = new SocksProxy();
socksProxy.setPattern(this._options.socksProxyPattern); socksProxy.setPattern(this._options.socksProxyPattern);
playwright.options.socksProxyPort = await socksProxy.listen(0); playwright.options.socksProxyPort = await socksProxy.listen(0);
@ -280,4 +282,5 @@ const defaultLaunchOptions: LaunchOptions = {
const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [ const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [
'headless', '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(() => ('{}')); const settings = await fs.promises.readFile(settingsFile, 'utf-8').catch(() => ('{}'));
await page.addInitScript( await page.addInitScript(
`(${String((settings: any) => { `(${String((settings: any) => {
// iframes w/ snapshots, etc.
if (location && location.protocol === 'data:')
return;
Object.entries(settings).map(([k, v]) => localStorage[k] = v); Object.entries(settings).map(([k, v]) => localStorage[k] = v);
(window as any).saveSettings = () => { (window as any).saveSettings = () => {
(window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage })); (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 sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined;
const callMetadata: CallMetadata = { const callMetadata: CallMetadata = {
id: `call@${id}`, id: `call@${id}`,
wallTime: validMetadata.wallTime, wallTime: validMetadata.wallTime || Date.now(),
location: validMetadata.location, location: validMetadata.location,
apiName: validMetadata.apiName, apiName: validMetadata.apiName,
internal: validMetadata.internal, internal: validMetadata.internal,

View file

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

View file

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

View file

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

View file

@ -261,8 +261,12 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return {}; return {};
// Network file survives across chunks, make a snapshot before returning the resulting entries. // Network file survives across chunks, make a snapshot before returning the resulting entries.
const suffix = state.chunkOrdinal ? `-${state.chunkOrdinal}` : ``; // We should pick a name starting with "traceName" and ending with .network.
const networkFile = path.join(state.tracesDir, state.traceName + `${suffix}.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); await fs.promises.copyFile(state.networkFile, networkFile);
const entries: NameValue[] = []; const entries: NameValue[] = [];
@ -489,7 +493,7 @@ function createBeforeActionTraceEvent(metadata: CallMetadata): trace.BeforeActio
class: metadata.type, class: metadata.type,
method: metadata.method, method: metadata.method,
params: metadata.params, params: metadata.params,
wallTime: metadata.wallTime || Date.now(), wallTime: metadata.wallTime,
pageId: metadata.pageId, pageId: metadata.pageId,
}; };
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-firefox", "name": "playwright-firefox",
"version": "1.32.0-next", "version": "1.32.3",
"description": "A high-level API to automate Firefox", "description": "A high-level API to automate Firefox",
"repository": "github:Microsoft/playwright", "repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev", "homepage": "https://playwright.dev",
@ -28,6 +28,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "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-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-react-jsx@7.20.7 (https://github.com/babel/babel)
- @babel/plugin-transform-typescript@7.20.2 (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/template@7.18.10 (https://github.com/babel/babel)
- @babel/traverse@7.20.1 (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) - @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 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 %% @babel/template@7.18.10 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
MIT License MIT License
@ -4102,6 +4130,6 @@ END OF update-browserslist-db@1.0.10 AND INFORMATION
SUMMARY BEGIN HERE SUMMARY BEGIN HERE
========================================= =========================================
Total Packages: 142 Total Packages: 143
========================================= =========================================
END OF SUMMARY END OF SUMMARY

View file

@ -28,7 +28,7 @@
"@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
"@babel/plugin-transform-modules-commonjs": "^7.19.6", "@babel/plugin-transform-modules-commonjs": "^7.19.6",
"@babel/plugin-transform-react-jsx": "^7.20.7", "@babel/plugin-transform-react-jsx": "^7.20.7",
"@babel/plugin-transform-typescript": "^7.20.2" "@babel/preset-typescript": "^7.18.6"
}, },
"devDependencies": { "devDependencies": {
"@types/babel__code-frame": "^7.0.3", "@types/babel__code-frame": "^7.0.3",
@ -724,6 +724,22 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.18.10", "version": "7.18.10",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
@ -1586,6 +1602,16 @@
"@babel/plugin-syntax-typescript": "^7.20.0" "@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": { "@babel/template": {
"version": "7.18.10", "version": "7.18.10",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", "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-syntax-optional-catch-binding": "^7.8.3",
"@babel/plugin-transform-modules-commonjs": "^7.19.6", "@babel/plugin-transform-modules-commonjs": "^7.19.6",
"@babel/plugin-transform-react-jsx": "^7.20.7", "@babel/plugin-transform-react-jsx": "^7.20.7",
"@babel/plugin-transform-typescript": "^7.20.2" "@babel/preset-typescript": "^7.18.6"
}, },
"devDependencies": { "devDependencies": {
"@types/babel__code-frame": "^7.0.3", "@types/babel__code-frame": "^7.0.3",

View file

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

View file

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

View file

@ -30,7 +30,6 @@ import type { FullResult } from '../reporter';
export function addTestCommands(program: Command) { export function addTestCommands(program: Command) {
addTestCommand(program); addTestCommand(program);
addUICommand(program);
addShowReportCommand(program); addShowReportCommand(program);
addListFilesCommand(program); addListFilesCommand(program);
} }
@ -38,7 +37,7 @@ export function addTestCommands(program: Command) {
function addTestCommand(program: Command) { function addTestCommand(program: Command) {
const command = program.command('test [test-filter...]'); const command = program.command('test [test-filter...]');
command.description('run tests with Playwright Test'); 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)); options.forEach(([name, description]) => command.option(name, description));
command.action(async (args, opts) => { command.action(async (args, opts) => {
try { try {
@ -59,30 +58,6 @@ Examples:
$ npx playwright test --project=webkit`); $ 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) { function addListFilesCommand(program: Command) {
const command = program.command('list-files [file-filter...]', { hidden: true }); const command = program.command('list-files [file-filter...]', { hidden: true });
command.description('List files with Playwright Test tests'); 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 kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'retain-on-failure'];
const sharedOptions: [string, string][] = [ const testOptions: [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][] = [
['--browser <browser>', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`], ['--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`], ['--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)`], ['--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)`], ['--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: ".*")`], ['-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`], ['-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`], ['--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`], ['--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)`], ['--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)`], ['--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"`], ['--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(', ')}`], ['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`],
['--ui', `Run tests in interactive UI mode`], ['--ui', `Run tests in interactive UI mode`],
['-u, --update-snapshots', `Update snapshots with actual results (default: only create missing snapshots)`], ['-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' }], _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) if (testIdAttribute)
playwrightLibrary.selectors.setTestIdAttribute(testIdAttribute); playwrightLibrary.selectors.setTestIdAttribute(testIdAttribute);
testInfo.snapshotSuffix = _snapshotSuffix; testInfo.snapshotSuffix = _snapshotSuffix;
@ -251,7 +251,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
const traceMode = normalizeTraceMode(trace); const traceMode = normalizeTraceMode(trace);
const defaultTraceOptions = { screenshots: true, snapshots: true, sources: true }; const defaultTraceOptions = { screenshots: true, snapshots: true, sources: true };
const traceOptions = typeof trace === 'string' ? defaultTraceOptions : { ...defaultTraceOptions, ...trace, mode: undefined }; 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 temporaryTraceFiles: string[] = [];
const temporaryScreenshots: string[] = []; const temporaryScreenshots: string[] = [];
const testInfoImpl = testInfo as TestInfoImpl; const testInfoImpl = testInfo as TestInfoImpl;
@ -603,7 +603,7 @@ type ParsedStackTrace = {
apiName: string; 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) if (!video)
return 'off'; return 'off';
let videoMode = typeof video === 'string' ? video : video.mode; 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)); 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) if (!trace)
return 'off'; return 'off';
let traceMode = typeof trace === 'string' ? trace : trace.mode; let traceMode = typeof trace === 'string' ? trace : trace.mode;

View file

@ -149,7 +149,6 @@ export class TeleReporterReceiver {
} }
private _onBegin(config: JsonConfig, projects: JsonProject[]) { private _onBegin(config: JsonConfig, projects: JsonProject[]) {
const removeMissing = config.listOnly;
for (const project of projects) { for (const project of projects) {
let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.id === project.id); let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.id === project.id);
if (!projectSuite) { if (!projectSuite) {
@ -159,7 +158,24 @@ export class TeleReporterReceiver {
} }
const p = this._parseProject(project); const p = this._parseProject(project);
projectSuite.project = () => p; 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); this._reporter.onBegin?.(this._parseConfig(config), this._rootSuite);
} }
@ -171,6 +187,7 @@ export class TeleReporterReceiver {
testResult.workerIndex = payload.workerIndex; testResult.workerIndex = payload.workerIndex;
testResult.parallelIndex = payload.parallelIndex; testResult.parallelIndex = payload.parallelIndex;
testResult.startTime = new Date(payload.startTime); testResult.startTime = new Date(payload.startTime);
testResult.statusEx = 'running';
this._reporter.onTestBegin?.(test, testResult); this._reporter.onTestBegin?.(test, testResult);
} }
@ -179,6 +196,7 @@ export class TeleReporterReceiver {
const result = test.resultsMap.get(payload.id)!; const result = test.resultsMap.get(payload.id)!;
result.duration = payload.duration; result.duration = payload.duration;
result.status = payload.status; result.status = payload.status;
result.statusEx = payload.status;
result.errors = payload.errors; result.errors = payload.errors;
result.attachments = payload.attachments; result.attachments = payload.attachments;
this._reporter.onTestEnd?.(test, result); 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) { for (const jsonSuite of jsonSuites) {
let targetSuite = parent.suites.find(s => s.title === jsonSuite.title); let targetSuite = parent.suites.find(s => s.title === jsonSuite.title);
if (!targetSuite) { if (!targetSuite) {
@ -271,16 +289,12 @@ export class TeleReporterReceiver {
targetSuite.location = jsonSuite.location; targetSuite.location = jsonSuite.location;
targetSuite._fileId = jsonSuite.fileId; targetSuite._fileId = jsonSuite.fileId;
targetSuite._parallelMode = jsonSuite.parallelMode; targetSuite._parallelMode = jsonSuite.parallelMode;
this._mergeSuitesInto(jsonSuite.suites, targetSuite, removeMissing); this._mergeSuitesInto(jsonSuite.suites, targetSuite);
this._mergeTestsInto(jsonSuite.tests, targetSuite, removeMissing); this._mergeTestsInto(jsonSuite.tests, targetSuite);
}
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[];
} }
} }
private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite, removeMissing: boolean) { private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) {
for (const jsonTest of jsonTests) { for (const jsonTest of jsonTests) {
let targetTest = parent.tests.find(s => s.title === jsonTest.title); let targetTest = parent.tests.find(s => s.title === jsonTest.title);
if (!targetTest) { if (!targetTest) {
@ -291,16 +305,13 @@ export class TeleReporterReceiver {
} }
this._updateTest(jsonTest, targetTest); 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 { private _updateTest(payload: JsonTestCase, test: TeleTestCase): TeleTestCase {
test.id = payload.testId; test.id = payload.testId;
test.expectedStatus = payload.expectedStatus; test.expectedStatus = payload.expectedStatus;
test.timeout = payload.timeout; test.timeout = payload.timeout;
test.location = payload.location;
test.annotations = payload.annotations; test.annotations = payload.annotations;
test.retries = payload.retries; test.retries = payload.retries;
return test; return test;
@ -355,7 +366,7 @@ export class TeleSuite implements SuitePrivate {
export class TeleTestCase implements reporterTypes.TestCase { export class TeleTestCase implements reporterTypes.TestCase {
title: string; title: string;
fn = () => {}; fn = () => {};
results: reporterTypes.TestResult[] = []; results: TeleTestResult[] = [];
location: Location; location: Location;
parent!: TeleSuite; parent!: TeleSuite;
@ -401,7 +412,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
this.resultsMap.clear(); this.resultsMap.clear();
} }
_createTestResult(id: string): reporterTypes.TestResult { _createTestResult(id: string): TeleTestResult {
this._clearResults(); this._clearResults();
const result: TeleTestResult = { const result: TeleTestResult = {
retry: this.results.length, retry: this.results.length,
@ -413,6 +424,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
stderr: [], stderr: [],
attachments: [], attachments: [],
status: 'skipped', status: 'skipped',
statusEx: 'scheduled',
steps: [], steps: [],
errors: [], errors: [],
stepMap: new Map(), stepMap: new Map(),
@ -428,6 +440,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
export type TeleTestResult = reporterTypes.TestResult & { export type TeleTestResult = reporterTypes.TestResult & {
stepMap: Map<string, reporterTypes.TestStep>; stepMap: Map<string, reporterTypes.TestStep>;
stepStack: (reporterTypes.TestStep | reporterTypes.TestResult)[]; stepStack: (reporterTypes.TestStep | reporterTypes.TestResult)[];
statusEx: reporterTypes.TestResult['status'] | 'scheduled' | 'running';
}; };
export type TeleFullProject = FullProject & { id: string }; 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 { colors } from 'playwright-core/lib/utilsBundle';
import type { Expect } from '../common/types'; import type { Expect } from '../common/types';
import { expectTypes, callLogText } from '../util'; import { expectTypes, callLogText } from '../util';
import { currentTestInfo } from '../common/globals';
import type { TestInfoErrorState } from '../worker/testInfo';
import { toBeTruthy } from './toBeTruthy'; import { toBeTruthy } from './toBeTruthy';
import { toEqual } from './toEqual'; import { toEqual } from './toEqual';
import { toExpectedTextValues, toMatchText } from './toMatchText'; import { toExpectedTextValues, toMatchText } from './toMatchText';
@ -328,22 +326,11 @@ export async function toPass(
timeout?: number, timeout?: number,
} = {}, } = {},
) { ) {
const testInfo = currentTestInfo();
const timeout = options.timeout !== undefined ? options.timeout : 0; 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 () => { const result = await pollAgainstTimeout<Error|undefined>(async () => {
try { try {
if (testStateBeforeToPassMatcher && testInfo)
testInfo._restoreErrorState(testStateBeforeToPassMatcher);
testStateBeforeToPassMatcher = testInfo?._saveErrorState();
await callback(); 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 }; return { continuePolling: this.isNot, result: undefined };
} catch (e) { } catch (e) {
return { continuePolling: !this.isNot, result: e }; return { continuePolling: !this.isNot, result: e };
@ -361,7 +348,5 @@ export async function toPass(
return { message, pass: this.isNot }; return { message, pass: this.isNot };
} }
if (testStateBeforeToPassMatcher && testInfo)
testInfo._restoreErrorState(testStateBeforeToPassMatcher);
return { pass: !this.isNot, message: () => '' }; return { pass: !this.isNot, message: () => '' };
} }

View file

@ -124,6 +124,8 @@ export class BaseReporter implements Reporter {
protected generateStartingMessage() { protected generateStartingMessage() {
const jobs = Math.min(this.config.workers, this.config._internal.maxConcurrentTestGroups); 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}` : ''; 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}`); 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) { override onBegin(config: FullConfig, suite: Suite) {
super.onBegin(config, suite); super.onBegin(config, suite);
console.log(this.generateStartingMessage()); const startingMessage = this.generateStartingMessage();
console.log(); if (startingMessage) {
console.log(startingMessage);
console.log();
}
} }
onTestBegin(test: TestCase, result: TestResult) { 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 type { Page } from 'playwright-core/lib/server/page';
import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils'; import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils';
import type { FullResult } from '../../reporter'; 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 type { FullConfigInternal } from '../common/types';
import { Multiplexer } from '../reporters/multiplexer'; import { Multiplexer } from '../reporters/multiplexer';
import { TeleReporterEmitter } from '../reporters/teleEmitter'; import { TeleReporterEmitter } from '../reporters/teleEmitter';
@ -28,13 +28,15 @@ import { createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForW
import { chokidar } from '../utilsBundle'; import { chokidar } from '../utilsBundle';
import type { FSWatcher } from 'chokidar'; import type { FSWatcher } from 'chokidar';
import { open } from '../utilsBundle'; import { open } from '../utilsBundle';
import ListReporter from '../reporters/list';
class UIMode { class UIMode {
private _config: FullConfigInternal; private _config: FullConfigInternal;
private _page!: Page; private _page!: Page;
private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined; private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined;
globalCleanup: (() => Promise<FullResult['status']>) | undefined; globalCleanup: (() => Promise<FullResult['status']>) | undefined;
private _testWatcher: FSWatcher | undefined; private _globalWatcher: Watcher;
private _testWatcher: Watcher;
private _originalStderr: (buffer: string | Uint8Array) => void; private _originalStderr: (buffer: string | Uint8Array) => void;
constructor(config: FullConfigInternal) { constructor(config: FullConfigInternal) {
@ -56,28 +58,16 @@ class UIMode {
config._internal.configCLIOverrides.use.trace = { mode: 'on', sources: false }; config._internal.configCLIOverrides.use.trace = { mode: 'on', sources: false };
this._originalStderr = process.stderr.write.bind(process.stderr); this._originalStderr = process.stderr.write.bind(process.stderr);
this._installGlobalWatcher(); this._globalWatcher = new Watcher('deep', () => this._dispatchEvent({ method: 'listChanged' }));
} this._testWatcher = new Watcher('flat', events => {
const collector = new Set<string>();
private _installGlobalWatcher(): FSWatcher { events.forEach(f => collectAffectedTestFiles(f.file, collector));
const projectDirs = new Set<string>(); this._dispatchEvent({ method: 'testFilesChanged', params: { testFileNames: [...collector] } });
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);
}); });
return watcher;
} }
async runGlobalSetup(): Promise<FullResult['status']> { async runGlobalSetup(): Promise<FullResult['status']> {
const reporter = await createReporter(this._config, 'watch'); const reporter = new Multiplexer([new ListReporter()]);
const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter); const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter);
reporter.onConfigure(this._config); reporter.onConfigure(this._config);
const context: TaskRunnerState = { const context: TaskRunnerState = {
@ -86,6 +76,7 @@ class UIMode {
phases: [], phases: [],
}; };
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0); const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0);
await reporter.onExit({ status });
if (status !== 'passed') { if (status !== 'passed') {
await globalCleanup(); await globalCleanup();
return status; return status;
@ -96,14 +87,16 @@ class UIMode {
async showUI() { async showUI() {
this._page = await showTraceViewer([], 'chromium', { app: 'watch.html', headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1' }); this._page = await showTraceViewer([], 'chromium', { app: 'watch.html', headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1' });
process.stdout.write = (chunk: string | Buffer) => { if (!process.env.PWTEST_DEBUG) {
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) }); process.stdout.write = (chunk: string | Buffer) => {
return true; this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) });
}; return true;
process.stderr.write = (chunk: string | Buffer) => { };
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) }); process.stderr.write = (chunk: string | Buffer) => {
return true; this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) });
}; return true;
};
}
const exitPromise = new ManualPromise(); const exitPromise = new ManualPromise();
this._page.on('close', () => exitPromise.resolve()); this._page.on('close', () => exitPromise.resolve());
let queue = Promise.resolve(); let queue = Promise.resolve();
@ -159,7 +152,13 @@ class UIMode {
const context: TaskRunnerState = { config: this._config, reporter, phases: [] }; const context: TaskRunnerState = { config: this._config, reporter, phases: [] };
clearCompilationCache(); clearCompilationCache();
reporter.onConfigure(this._config); 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[]) { private async _runTests(testIds: string[]) {
@ -187,22 +186,12 @@ class UIMode {
} }
private async _watchFiles(fileNames: string[]) { private async _watchFiles(fileNames: string[]) {
if (this._testWatcher)
await this._testWatcher.close();
if (!fileNames.length)
return;
const files = new Set<string>(); const files = new Set<string>();
for (const fileName of fileNames) { for (const fileName of fileNames) {
files.add(fileName); files.add(fileName);
dependenciesForTestFile(fileName).forEach(file => files.add(file)); dependenciesForTestFile(fileName).forEach(file => files.add(file));
} }
this._testWatcher.update([...files], true);
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 } });
});
} }
private async _stopTests() { private async _stopTests() {
@ -235,3 +224,54 @@ function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): Stdi
return { type, buffer: chunk.toString('base64') }; return { type, buffer: chunk.toString('base64') };
return { type, text: chunk }; 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 { getContainedPath, normalizeAndSaveAttachment, sanitizeForFilePath, serializeError, trimLongString } from '../util';
import type * as trace from '@trace/trace'; import type * as trace from '@trace/trace';
export type TestInfoErrorState = {
status: TestStatus,
errors: TestInfoError[],
hasHardError: boolean,
};
interface TestStepInternal { interface TestStepInternal {
complete(result: { error?: Error | TestInfoError }): void; complete(result: { error?: Error | TestInfoError }): void;
title: string; title: string;
@ -249,7 +243,6 @@ export class TestInfoImpl implements TestInfo {
stepId, stepId,
...data, ...data,
location, location,
wallTime: Date.now(),
}; };
this._onStepBegin(payload); this._onStepBegin(payload);
return step; return step;
@ -269,20 +262,6 @@ export class TestInfoImpl implements TestInfo {
this.errors.push(error); 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> { async _runAsStep<T>(cb: () => Promise<T>, stepInfo: Omit<TestStepInternal, 'complete' | 'wallTime'>): Promise<T> {
const step = this._addStep({ ...stepInfo, wallTime: Date.now() }); const step = this._addStep({ ...stepInfo, wallTime: Date.now() });
try { 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 * 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; url?: string;

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright", "name": "playwright",
"version": "1.32.0-next", "version": "1.32.3",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": "github:Microsoft/playwright", "repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev", "homepage": "https://playwright.dev",
@ -28,6 +28,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "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. // through the dispatcher, so is always excluded from inspector / tracing.
isServerSide?: boolean; isServerSide?: boolean;
// Client wall time. // Client wall time.
wallTime?: number; wallTime: number;
location?: { file: string, line?: number, column?: number }; location?: { file: string, line?: number, column?: number };
log: string[]; log: string[];
error?: SerializedError; error?: SerializedError;

View file

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

View file

@ -104,7 +104,8 @@ export class SnapshotRenderer {
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : ''; const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
html = prefix + [ html = prefix + [
'<style>*,*::before,*::after { visibility: hidden }</style>', '<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>` `<script>${snapshotScript()}</script>`
].join('') + html; ].join('') + html;

View file

@ -42,15 +42,13 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
try { try {
await traceModel.load(traceUrl, progress); await traceModel.load(traceUrl, progress);
} catch (error: any) { } 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')) 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.'); 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.`); 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()); const snapshotServer = new SnapshotServer(traceModel.storage());
loadedTraces.set(traceUrl, { traceModel, snapshotServer }); loadedTraces.set(traceUrl, { traceModel, snapshotServer });

View file

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

View file

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

View file

@ -60,7 +60,7 @@ const renderAction = (
revealConsole: () => void revealConsole: () => void
) => { ) => {
const { errors, warnings } = modelUtil.stats(action); 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 = ''; let time: string = '';
if (action.endTime) 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')) if ((name === 'value' && isEval) || (name === 'received' && event.method === 'expect'))
value = parseSerializedValue(value, new Array(10).fill({ handle: '<handle>' })); value = parseSerializedValue(value, new Array(10).fill({ handle: '<handle>' }));
if (name === 'selector') 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; const type = typeof value;
if (type !== 'object' || value === null) if (type !== 'object' || value === null)
return { text: String(value), type, name }; return { text: String(value), type, name };

View file

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

View file

@ -24,6 +24,10 @@
overflow: hidden; overflow: hidden;
} }
.snapshot-tab .toolbar {
background-color: var(--vscode-sideBar-background);
}
.snapshot-controls { .snapshot-controls {
flex: none; flex: none;
background-color: var(--vscode-sideBar-background); 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); 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%; width: 100%;
height: calc(100% - var(--window-header-height)); height: calc(100% - var(--window-header-height));
position: relative;
}
iframe[name=snapshot] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none; border: none;
background: white; background: white;
visibility: hidden;
}
iframe.snapshot-visible[name=snapshot] {
visibility: visible;
} }
.no-snapshot { .no-snapshot {
@ -153,4 +171,5 @@ body.dark-mode .window-header {
.snapshot-tab .cm-wrapper { .snapshot-tab .cm-wrapper {
line-height: 23px; line-height: 23px;
margin-right: 4px;
} }

View file

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

View file

@ -21,3 +21,13 @@
display: flex; display: flex;
flex-direction: row; 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 type { ActionTraceEvent } from '@trace/trace';
import { SplitView } from '@web/components/splitView'; import { SplitView } from '@web/components/splitView';
import * as React from 'react'; import * as React from 'react';
import { useAsyncMemo } from './helpers'; import { useAsyncMemo } from '@web/uiUtils';
import './sourceTab.css'; import './sourceTab.css';
import { StackTraceView } from './stackTrace'; import { StackTraceView } from './stackTrace';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import type { SourceHighlight } from '@web/components/codeMirrorWrapper'; import type { SourceHighlight } from '@web/components/codeMirrorWrapper';
import type { SourceModel } from './modelUtil'; import type { SourceModel } from './modelUtil';
import type { StackFrame } from '@protocol/channels';
export const SourceTab: React.FunctionComponent<{ export const SourceTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined, action: ActionTraceEvent | undefined,
sources: Map<string, SourceModel>, sources: Map<string, SourceModel>,
hideStackFrames?: boolean, hideStackFrames?: boolean,
}> = ({ action, sources, hideStackFrames }) => { rootDir?: string,
fallbackLocation?: StackFrame,
}> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => {
const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>(); const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedFrame, setSelectedFrame] = React.useState<number>(0); const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
@ -39,31 +42,42 @@ export const SourceTab: React.FunctionComponent<{
} }
}, [action, lastAction, setLastAction, setSelectedFrame]); }, [action, lastAction, setLastAction, setSelectedFrame]);
const source = useAsyncMemo<SourceModel>(async () => { const { source, highlight, targetLine, fileName } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[] }>(async () => {
const file = action?.stack?.[selectedFrame].file; const location = action?.stack?.[selectedFrame] || fallbackLocation;
if (!file) if (!location?.file)
return { errors: [], content: undefined }; return { source: { errors: [], content: undefined }, targetLine: 0, highlight: [] };
const source = sources.get(file)!;
if (source.content === undefined) { let source = sources.get(location.file);
const sha1 = await calculateSha1(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 { try {
let response = await fetch(`sha1/src@${sha1}.txt`); let response = await fetch(`sha1/src@${sha1}.txt`);
if (response.status === 404) if (response.status === 404)
response = await fetch(`file?path=${file}`); response = await fetch(`file?path=${location.file}`);
source.content = await response.text(); source.content = await response.text();
} catch { } catch {
source.content = `<Unable to read "${file}">`; source.content = `<Unable to read "${location.file}">`;
} }
} }
return source; return { source, highlight, targetLine, fileName };
}, [action, selectedFrame], { errors: [], content: 'Loading\u2026' }); }, [action, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] });
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 <SplitView sidebarSize={200} orientation='horizontal' sidebarHidden={hideStackFrames}> 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} /> <StackTraceView action={action} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />
</SplitView>; </SplitView>;
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,19 +15,34 @@
*/ */
.toolbar { .toolbar {
position: relative;
display: flex; display: flex;
box-shadow: var(--box-shadow);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-sideBarTitle-foreground); color: var(--vscode-sideBarTitle-foreground);
min-height: 35px; min-height: 35px;
align-items: center; align-items: center;
flex: none; flex: none;
z-index: 2; padding-right: 4px;
} }
.toolbar-linewrap { .toolbar:after {
content: '';
display: block; 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 { .toolbar input {

View file

@ -17,11 +17,15 @@
import './toolbar.css'; import './toolbar.css';
import * as React from 'react'; import * as React from 'react';
export interface ToolbarProps { type ToolbarProps = {
} noShadow?: boolean;
noMinHeight?: boolean;
};
export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({ 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); color: var(--vscode-sideBarTitle-foreground);
background: transparent; background: transparent;
padding: 4px; padding: 4px;
margin: 0 4px;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -43,3 +42,7 @@
.toolbar-button.toggled { .toolbar-button.toggled {
color: var(--vscode-notificationLink-foreground); 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} className={className}
onMouseDown={preventDefault} onMouseDown={preventDefault}
onClick={onClick} onClick={onClick}
onDoubleClick={preventDefault}
title={title} title={title}
disabled={!!disabled}> disabled={!!disabled}>
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>} {icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
@ -48,4 +49,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
</button>; </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 * as React from 'react';
import './xtermWrapper.css'; import './xtermWrapper.css';
import type { ITheme, Terminal } from 'xterm'; import type { ITheme, Terminal } from 'xterm';
import type { FitAddon } from 'xterm-addon-fit';
import type { XtermModule } from './xtermModule'; 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 = { export type XtermDataSource = {
pending: (string | Uint8Array)[]; pending: (string | Uint8Array)[];
@ -28,12 +30,22 @@ export type XtermDataSource = {
}; };
export const XtermWrapper: React.FC<{ source: 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 [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(() => { React.useEffect(() => {
addThemeListener(setTheme);
return () => removeThemeListener(setTheme);
}, []);
React.useEffect(() => {
const oldSourceWrite = source.write;
const oldSourceClear = source.clear;
(async () => { (async () => {
// Always load the module first. // Always load the module first.
const { Terminal, FitAddon } = await modulePromise; const { Terminal, FitAddon } = await modulePromise;
@ -41,15 +53,19 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
if (!element) if (!element)
return; return;
if (terminal.current) const terminalTheme = theme === 'dark-mode' ? darkTheme : lightTheme;
if (terminal.current && terminal.current.terminal.options.theme === terminalTheme)
return; return;
if (terminal.current)
element.textContent = '';
const newTerminal = new Terminal({ const newTerminal = new Terminal({
convertEol: true, convertEol: true,
fontSize: 13, fontSize: 13,
scrollback: 10000, scrollback: 10000,
fontFamily: 'var(--vscode-editor-font-family)', fontFamily: 'var(--vscode-editor-font-family)',
theme: isDarkTheme() ? darkTheme : lightTheme theme: terminalTheme,
}); });
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
@ -66,16 +82,26 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
}; };
newTerminal.open(element); newTerminal.open(element);
fitAddon.fit(); fitAddon.fit();
terminal.current = newTerminal; terminal.current = { terminal: newTerminal, fitAddon };
const resizeObserver = new ResizeObserver(() => {
source.resize(newTerminal.cols, newTerminal.rows);
fitAddon.fit();
});
resizeObserver.observe(element);
})(); })();
}, [modulePromise, terminal, xtermElement, source]); return () => {
return <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}> source.clear = oldSourceClear;
</div>; 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 = { const lightTheme: ITheme = {

View file

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

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

Binary file not shown.

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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'; import { showTraceViewer } from '../../packages/playwright-core/lib/server';
type BaseTestFixtures = { type BaseTestFixtures = {
@ -51,7 +51,7 @@ class TraceViewerPage {
this.consoleStacks = page.locator('.console-stack'); this.consoleStacks = page.locator('.console-stack');
this.stackFrames = page.getByTestId('stack-trace').locator('.list-view-entry'); this.stackFrames = page.getByTestId('stack-trace').locator('.list-view-entry');
this.networkRequests = page.locator('.network-request-title'); 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) { async actionIconsText(action: string) {
@ -96,15 +96,11 @@ class TraceViewerPage {
return result.sort(); return result.sort();
} }
async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<Frame> { async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<FrameLocator> {
const existing = this.page.mainFrame().childFrames()[0]; await this.selectAction(actionName, ordinal);
await Promise.all([ while (this.page.frames().length < (hasSubframe ? 4 : 3))
existing ? existing.waitForNavigation() as any : Promise.resolve(),
this.selectAction(actionName, ordinal),
]);
while (this.page.frames().length < (hasSubframe ? 3 : 2))
await this.page.waitForEvent('frameattached'); 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(); await snapshotter.reset();
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2'); const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
const html2 = snapshot2.render().html; 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 }) => { 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. // Render snapshot, check expectations.
const snapshotFrame = await traceViewer.snapshotFrame('page.evaluate', 0, true); 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'); 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. // Render snapshot, check expectations.
const snapshotFrame = await traceViewer.snapshotFrame('page.evaluate', 0, true); const snapshotFrame = await traceViewer.snapshotFrame('page.evaluate', 0, true);
await expect(snapshotFrame.childFrames()[0].locator('svg')).toBeVisible(); await expect(snapshotFrame.frameLocator('iframe').locator('svg')).toBeVisible();
const content = await snapshotFrame.childFrames()[0].content(); 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"`); 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'); const frame = await traceViewer.snapshotFrame('page.evaluate');
await frame.waitForSelector('button'); await expect(frame.locator('button')).toHaveCSS('color', 'rgb(255, 0, 0)');
const buttonColor = await frame.$eval('button', button => { await expect(frame.locator('div')).toHaveCSS('color', 'rgb(0, 0, 255)');
return window.getComputedStyle(button).color; await expect(frame.locator('span')).toHaveCSS('color', 'rgb(0, 0, 255)');
});
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)');
}); });
test('should work with adopted style sheets and replace/replaceSync', async ({ page, runAndTrace, browserName }) => { 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); const frame = await traceViewer.snapshotFrame('page.evaluate', 0);
await frame.waitForSelector('button'); await expect(frame.locator('button')).toHaveCSS('color', 'rgb(255, 0, 0)');
const buttonColor = await frame.$eval('button', button => {
return window.getComputedStyle(button).color;
});
expect(buttonColor).toBe('rgb(255, 0, 0)');
} }
{ {
const frame = await traceViewer.snapshotFrame('page.evaluate', 1); const frame = await traceViewer.snapshotFrame('page.evaluate', 1);
await frame.waitForSelector('button'); await expect(frame.locator('button')).toHaveCSS('color', 'rgb(0, 0, 255)');
const buttonColor = await frame.$eval('button', button => {
return window.getComputedStyle(button).color;
});
expect(buttonColor).toBe('rgb(0, 0, 255)');
} }
{ {
const frame = await traceViewer.snapshotFrame('page.evaluate', 2); const frame = await traceViewer.snapshotFrame('page.evaluate', 2);
await frame.waitForSelector('button'); await expect(frame.locator('button')).toHaveCSS('color', 'rgb(0, 255, 0)');
const buttonColor = await frame.$eval('button', button => {
return window.getComputedStyle(button).color;
});
expect(buttonColor).toBe('rgb(0, 255, 0)');
} }
}); });
@ -402,8 +380,7 @@ test('should restore scroll positions', async ({ page, runAndTrace, browserName
// Render snapshot, check expectations. // Render snapshot, check expectations.
const frame = await traceViewer.snapshotFrame('scrollIntoViewIfNeeded'); const frame = await traceViewer.snapshotFrame('scrollIntoViewIfNeeded');
const div = await frame.waitForSelector('div'); expect(await frame.locator('div').evaluate(div => div.scrollTop)).toBe(136);
expect(await div.evaluate(div => div.scrollTop)).toBe(136);
}); });
test('should restore control values', async ({ page, runAndTrace }) => { 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).toHaveText('old');
await expect(textarea).toHaveValue('hello'); await expect(textarea).toHaveValue('hello');
expect(await frame.$eval('option >> nth=0', o => o.hasAttribute('selected'))).toBe(false); expect(await frame.locator('option >> nth=0').evaluate(o => o.hasAttribute('selected'))).toBe(false);
expect(await frame.$eval('option >> nth=1', o => o.hasAttribute('selected'))).toBe(true); expect(await frame.locator('option >> nth=1').evaluate(o => o.hasAttribute('selected'))).toBe(true);
expect(await frame.$eval('option >> nth=2', o => o.hasAttribute('selected'))).toBe(false); expect(await frame.locator('option >> nth=2').evaluate(o => o.hasAttribute('selected'))).toBe(false);
expect(await frame.locator('select').evaluate(s => { await expect(frame.locator('select')).toHaveValues(['opt1', 'opt3']);
const options = [...(s as HTMLSelectElement).selectedOptions];
return options.map(option => option.value);
})).toEqual(['opt1', 'opt3']);
}); });
test('should work with meta CSP', async ({ page, runAndTrace, browserName }) => { 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. // Render snapshot, check expectations.
const frame = await traceViewer.snapshotFrame('$eval'); const frame = await traceViewer.snapshotFrame('$eval');
await frame.waitForSelector('div');
// Should render shadow dom with post-processing script. // 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 }) => { 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'); const frame = await traceViewer.snapshotFrame('setContent');
await frame.waitForSelector('div'); await frame.locator('div').waitFor();
const padding = await frame.$eval('body', body => window.getComputedStyle(body).paddingLeft); await expect(frame.locator('body')).toHaveCSS('padding-left', '42px');
expect(padding).toBe('42px');
}); });
test('should handle src=blob', async ({ page, server, runAndTrace, browserName }) => { 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 frame = await traceViewer.snapshotFrame('page.evaluate');
const img = await frame.waitForSelector('img'); const size = await frame.locator('img').evaluate(e => (e as HTMLImageElement).naturalWidth);
const size = await img.evaluate(e => (e as HTMLImageElement).naturalWidth);
expect(size).toBe(10); expect(size).toBe(10);
}); });
test('should register custom elements', async ({ page, server, runAndTrace }) => { test('should register custom elements', async ({ page, server, runAndTrace }) => {
const traceViewer = await runAndTrace(async () => { const traceViewer = await runAndTrace(async () => {
page.on('console', console.log);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => { await page.evaluate(() => {
customElements.define('my-element', class extends HTMLElement { 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'); 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 }) => { test('should include requestUrl in route.continue', async ({ page, runAndTrace, server }) => {
await page.route('**/*', route => { await page.route('**/*', route => {
route.continue({ url: server.EMPTY_PAGE }); route.continue({ url: server.EMPTY_PAGE });
@ -767,8 +750,7 @@ test('should serve overridden request', async ({ page, runAndTrace, server }) =>
}); });
// Render snapshot, check expectations. // Render snapshot, check expectations.
const snapshotFrame = await traceViewer.snapshotFrame('page.goto'); const snapshotFrame = await traceViewer.snapshotFrame('page.goto');
const color = await snapshotFrame.locator('body').evaluate(body => getComputedStyle(body).backgroundColor); await expect(snapshotFrame.locator('body')).toHaveCSS('background-color', 'rgb(255, 0, 0)');
expect(color).toBe('rgb(255, 0, 0)');
}); });
test('should display waitForLoadState even if did not wait for it', async ({ runAndTrace, server, page }) => { 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'); const snapshot = await traceViewer.snapshotFrame('page.setContent');
await traceViewer.page.getByTitle('Pick locator').click(); 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' })`); 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 traceViewer.page.keyboard.type('button');
await expect(snapshot.locator('x-pw-glass')).toBeVisible(); 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.exitCode).toBe(0);
expect(result.passed).toBe(1); 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'] }); }, undefined, undefined, { additionalArgs: ['--pass-with-no-tests'] });
expect(result.exitCode).toBe(0); 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 }) => { 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(); }).toPass();
expect(i).toBe(3); 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.exitCode).toBe(0);
expect(result.passed).toBe(3); expect(result.passed).toBe(2);
}); });
test('should respect timeout', async ({ runInlineTest }) => { test('should respect timeout', async ({ runInlineTest }) => {
@ -159,74 +152,12 @@ test('should use custom message', async ({ runInlineTest }) => {
expect(result.failed).toBe(1); 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 }) => { test('should work with soft', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'a.spec.ts': ` 'a.spec.ts': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('should respect soft', async () => { test('should respect soft', async () => {
await test.expect.soft(() => { await expect.soft(() => {
expect(1).toBe(3); expect(1).toBe(3);
}).toPass({ timeout: 1000 }); }).toPass({ timeout: 1000 });
expect.soft(2).toBe(3); 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); 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) => { test('should save sources when requested', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'playwright.config.ts': `
@ -286,3 +322,21 @@ test('should respect --trace', async ({ runInlineTest }, testInfo) => {
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy(); expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.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 { cleanEnv, cliEntrypoint, removeFolderAsync, test as base, writeFiles } from './playwright-test-fixtures';
import type { Files, RunOptions } from './playwright-test-fixtures'; import type { Files, RunOptions } from './playwright-test-fixtures';
import type { Browser, Page, TestInfo } from './stable-test-runner'; 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 = { type Fixtures = {
runUITest: (files: Files, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<Page>; runUITest: (files: Files, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<Page>;
createLatch: () => Latch;
}; };
export function dumpTestTree(page: Page): () => Promise<string> { export function dumpTestTree(page: Page, options: { time?: boolean } = {}): () => Promise<string> {
return () => page.getByTestId('test-tree').evaluate(async treeElement => { return () => page.getByTestId('test-tree').evaluate(async (treeElement, options) => {
function iconName(iconElement: Element): string { function iconName(iconElement: Element): string {
const icon = iconElement.className.replace('codicon codicon-', ''); const icon = iconElement.className.replace('codicon codicon-', '');
if (icon === 'chevron-right') if (icon === 'chevron-right')
@ -48,6 +56,8 @@ export function dumpTestTree(page: Page): () => Promise<string> {
return '👁'; return '👁';
if (icon === 'loading') if (icon === 'loading')
return '↻'; return '↻';
if (icon === 'clock')
return '🕦';
return icon; return icon;
} }
@ -60,23 +70,27 @@ export function dumpTestTree(page: Page): () => Promise<string> {
const indent = listItem.querySelectorAll('.list-view-indent').length; const indent = listItem.querySelectorAll('.list-view-indent').length;
const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : ''; const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : '';
const selected = listItem.classList.contains('selected') ? ' <=' : ''; 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 '; return '\n' + result.join('\n') + '\n ';
}); }, options);
} }
export const test = base export const test = base
.extend<Fixtures>({ .extend<Fixtures>({
runUITest: async ({ childProcess, playwright, headless }, use, testInfo: TestInfo) => { 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-')); const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-'));
let testProcess: TestChildProcess | undefined; let testProcess: TestChildProcess | undefined;
let browser: Browser | undefined; let browser: Browser | undefined;
await use(async (files: Files, env: NodeJS.ProcessEnv = {}, options: RunOptions = {}) => { await use(async (files: Files, env: NodeJS.ProcessEnv = {}, options: RunOptions = {}) => {
const baseDir = await writeFiles(testInfo, files, true); const baseDir = await writeFiles(testInfo, files, true);
testProcess = childProcess({ testProcess = childProcess({
command: ['node', cliEntrypoint, 'ui', '--workers=1', ...(options.additionalArgs || [])], command: ['node', cliEntrypoint, 'test', '--ui', '--workers=1', ...(options.additionalArgs || [])],
env: { env: {
...cleanEnv(env), ...cleanEnv(env),
PWTEST_UNDER_TEST: '1', PWTEST_UNDER_TEST: '1',
@ -98,6 +112,22 @@ export const test = base
await testProcess?.close(); await testProcess?.close();
await removeFolderAsync(cacheDir); 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'; 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(); 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(); onePromise.resolve();
await expect( await expect(
page.frameLocator('id=snapshot').locator('body'), page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('body'),
'verify snapshot' 'verify snapshot'
).toHaveText('One'); ).toHaveText('One', { timeout: 15000 });
await expect(listItem).toHaveText([ await expect(listItem).toHaveText([
/browserContext.newPage[\d.]+m?s/, /browserContext.newPage[\d.]+m?s/,
/page.gotohttp:\/\/localhost:\d+\/one.html[\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(); twoPromise.resolve();
await expect( await expect(
page.frameLocator('id=snapshot').locator('body'), page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('body'),
'verify snapshot' 'verify snapshot'
).toHaveText('Two'); ).toHaveText('Two');

View file

@ -59,6 +59,27 @@ test('should run visible', async ({ runUITest }) => {
passes passes
skipped 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 }) => { 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 expect(page.getByTitle('Stop')).toBeDisabled();
await page.getByTitle('Run all').click(); await page.getByTitle('Run all').click();
@ -217,7 +238,7 @@ test('should stop', async ({ runUITest }) => {
test 0 test 0
test 1 test 1
test 2 test 2
test 3 🕦 test 3
`); `);
await expect(page.getByTitle('Run all')).toBeDisabled(); await expect(page.getByTitle('Run all')).toBeDisabled();
@ -233,3 +254,55 @@ test('should stop', async ({ runUITest }) => {
test 3 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 }) => { test('should expand / collapse groups', async ({ runUITest }) => {
const page = await runUITest(basicTestTree); const page = await runUITest(basicTestTree);
await page.getByText('suite').click(); await page.getByTestId('test-tree').getByText('suite').click();
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(` await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts a.test.ts
@ -104,7 +104,7 @@ test('should expand / collapse groups', async ({ runUITest }) => {
suite <= suite <=
`); `);
await page.getByText('passes').first().click(); await page.getByTestId('test-tree').getByText('passes').first().click();
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(` await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts <= a.test.ts <=
@ -117,3 +117,108 @@ test('should expand / collapse groups', async ({ runUITest }) => {
a.test.ts <= 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 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.getByText('fails').click();
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Watch').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 page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Run').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
@ -51,3 +57,208 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
fails 👁 <= 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('*/*'); 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 }) => { test('should create multiple servers', async ({ runInlineTest }, { workerIndex }) => {
const port = workerIndex * 2 + 10500; const port = workerIndex * 2 + 10500;
const result = await runInlineTest({ const result = await runInlineTest({