Compare commits
40 commits
main
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679ee37d0f | ||
|
|
710674a0fe | ||
|
|
cb419e75cd | ||
|
|
ce33ec23c7 | ||
|
|
ed7a560ad2 | ||
|
|
b139ffc174 | ||
|
|
d59972aeb7 | ||
|
|
c132756306 | ||
|
|
847b546794 | ||
|
|
1869bd28d6 | ||
|
|
8bc3e0b6cf | ||
|
|
c7d84f5f37 | ||
|
|
e169cd394a | ||
|
|
72382faddc | ||
|
|
6b2858f0fb | ||
|
|
a0b4bd178d | ||
|
|
f622457b33 | ||
|
|
bc3ec153d3 | ||
|
|
3f2640336c | ||
|
|
08422f0651 | ||
|
|
8693fd4743 | ||
|
|
98ff2a891a | ||
|
|
75b429d143 | ||
|
|
b8f802910c | ||
|
|
39c3482980 | ||
|
|
0646773e85 | ||
|
|
e75fe015cf | ||
|
|
497c89dcfb | ||
|
|
b6e9f1fa53 | ||
|
|
620310ffb2 | ||
|
|
eed74036e8 | ||
|
|
80081692cd | ||
|
|
2fed4b6073 | ||
|
|
940078e06c | ||
|
|
f4f2bdd2ac | ||
|
|
53c40e24d2 | ||
|
|
ec76a817ed | ||
|
|
82cd1789b2 | ||
|
|
90de09668e | ||
|
|
3e8b14031b |
|
|
@ -176,7 +176,7 @@ jobs:
|
|||
name: 'Playwright Tests'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
|
|
@ -194,7 +194,7 @@ jobs:
|
|||
name: 'Playwright Tests'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
|
|
@ -218,7 +218,7 @@ jobs:
|
|||
name: 'Playwright Tests'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
|
|
@ -239,7 +239,7 @@ jobs:
|
|||
name: 'Playwright Tests'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup dotnet
|
||||
|
|
@ -264,7 +264,7 @@ jobs:
|
|||
name: 'Playwright Tests - ${{ matrix.project }} - Shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }}'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -299,7 +299,7 @@ jobs:
|
|||
- deployment: Run_E2E_Tests
|
||||
pool:
|
||||
vmImage: ubuntu-20.04
|
||||
container: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
container: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
environment: testing
|
||||
strategy:
|
||||
runOnce:
|
||||
|
|
@ -325,7 +325,7 @@ jobs:
|
|||
- deployment: Run_E2E_Tests
|
||||
pool:
|
||||
vmImage: ubuntu-20.04
|
||||
container: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
container: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
environment: testing
|
||||
strategy:
|
||||
runOnce:
|
||||
|
|
@ -369,7 +369,7 @@ Running Playwright on CircleCI is very similar to running on GitHub Actions. In
|
|||
executors:
|
||||
pw-focal-development:
|
||||
docker:
|
||||
- image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
- image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
```
|
||||
|
||||
Note: When using the docker agent definition, you are specifying the resource class of where playwright runs to the 'medium' tier [here](https://circleci.com/docs/configuration-reference?#docker-execution-environment). The default behavior of Playwright is to set the number of workers to the detected core count (2 in the case of the medium tier). Overriding the number of workers to greater than this number will cause unnecessary timeouts and failures.
|
||||
|
|
@ -403,7 +403,7 @@ to run tests on Jenkins.
|
|||
|
||||
```groovy
|
||||
pipeline {
|
||||
agent { docker { image 'mcr.microsoft.com/playwright:v1.32.0-focal' } }
|
||||
agent { docker { image 'mcr.microsoft.com/playwright:v1.32.3-focal' } }
|
||||
stages {
|
||||
stage('e2e-tests') {
|
||||
steps {
|
||||
|
|
@ -421,7 +421,7 @@ pipeline {
|
|||
Bitbucket Pipelines can use public [Docker images as build environments](https://confluence.atlassian.com/bitbucket/use-docker-images-as-build-environments-792298897.html). To run Playwright tests on Bitbucket, use our public Docker image ([see Dockerfile](./docker.md)).
|
||||
|
||||
```yml
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
|
|
@ -434,7 +434,7 @@ stages:
|
|||
|
||||
tests:
|
||||
stage: test
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
script:
|
||||
...
|
||||
```
|
||||
|
|
@ -450,7 +450,7 @@ stages:
|
|||
|
||||
tests:
|
||||
stage: test
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
parallel: 7
|
||||
script:
|
||||
- npm ci
|
||||
|
|
@ -465,7 +465,7 @@ stages:
|
|||
|
||||
tests:
|
||||
stage: test
|
||||
image: mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
parallel:
|
||||
matrix:
|
||||
- PROJECT: ['chromium', 'webkit']
|
||||
|
|
|
|||
|
|
@ -18,19 +18,19 @@ This Docker image is intended to be used for testing and development purposes on
|
|||
### Pull the image
|
||||
|
||||
```bash js
|
||||
docker pull mcr.microsoft.com/playwright:v1.32.0-focal
|
||||
docker pull mcr.microsoft.com/playwright:v1.32.3-focal
|
||||
```
|
||||
|
||||
```bash python
|
||||
docker pull mcr.microsoft.com/playwright/python:v1.32.0-focal
|
||||
docker pull mcr.microsoft.com/playwright/python:v1.32.3-focal
|
||||
```
|
||||
|
||||
```bash csharp
|
||||
docker pull mcr.microsoft.com/playwright/dotnet:v1.32.0-focal
|
||||
docker pull mcr.microsoft.com/playwright/dotnet:v1.32.3-focal
|
||||
```
|
||||
|
||||
```bash java
|
||||
docker pull mcr.microsoft.com/playwright/java:v1.32.0-focal
|
||||
docker pull mcr.microsoft.com/playwright/java:v1.32.3-focal
|
||||
```
|
||||
|
||||
### Run the image
|
||||
|
|
@ -42,19 +42,19 @@ By default, the Docker image will use the `root` user to run the browsers. This
|
|||
On trusted websites, you can avoid creating a separate user and use root for it since you trust the code which will run on the browsers.
|
||||
|
||||
```bash js
|
||||
docker run -it --rm --ipc=host mcr.microsoft.com/playwright:v1.32.0-focal /bin/bash
|
||||
docker run -it --rm --ipc=host mcr.microsoft.com/playwright:v1.32.3-focal /bin/bash
|
||||
```
|
||||
|
||||
```bash python
|
||||
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/python:v1.32.0-focal /bin/bash
|
||||
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/python:v1.32.3-focal /bin/bash
|
||||
```
|
||||
|
||||
```bash csharp
|
||||
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/dotnet:v1.32.0-focal /bin/bash
|
||||
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/dotnet:v1.32.3-focal /bin/bash
|
||||
```
|
||||
|
||||
```bash java
|
||||
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:v1.32.0-focal /bin/bash
|
||||
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:v1.32.3-focal /bin/bash
|
||||
```
|
||||
|
||||
#### Crawling and scraping
|
||||
|
|
@ -62,19 +62,19 @@ docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:v1.32.0-focal /
|
|||
On untrusted websites, it's recommended to use a separate user for launching the browsers in combination with the seccomp profile. Inside the container or if you are using the Docker image as a base image you have to use `adduser` for it.
|
||||
|
||||
```bash js
|
||||
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright:v1.32.0-focal /bin/bash
|
||||
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright:v1.32.3-focal /bin/bash
|
||||
```
|
||||
|
||||
```bash python
|
||||
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/python:v1.32.0-focal /bin/bash
|
||||
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/python:v1.32.3-focal /bin/bash
|
||||
```
|
||||
|
||||
```bash csharp
|
||||
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/dotnet:v1.32.0-focal /bin/bash
|
||||
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/dotnet:v1.32.3-focal /bin/bash
|
||||
```
|
||||
|
||||
```bash java
|
||||
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/java:v1.32.0-focal /bin/bash
|
||||
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/java:v1.32.3-focal /bin/bash
|
||||
```
|
||||
|
||||
[`seccomp_profile.json`](https://github.com/microsoft/playwright/blob/main/utils/docker/seccomp_profile.json) is needed to run Chromium with sandbox. This is a [default Docker seccomp profile](https://github.com/docker/engine/blob/d0d99b04cf6e00ed3fc27e81fc3d94e7eda70af3/profiles/seccomp/default.json) with extra user namespace cloning permissions:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,26 @@ title: "Release notes"
|
|||
toc_max_heading_level: 2
|
||||
---
|
||||
|
||||
## Version 1.32
|
||||
|
||||
### New APIs
|
||||
|
||||
- New options [`option: updateMode`] and [`option: updateContent`] in [`method: Page.routeFromHAR`] and [`method: BrowserContext.routeFromHAR`].
|
||||
- Chaining existing locator objects, see [locator docs](./locators.md#chaining-locators) for details.
|
||||
- New option [`option: name`] in method [`method: Tracing.startChunk`].
|
||||
|
||||
### Browser Versions
|
||||
|
||||
* Chromium 112.0.5615.29
|
||||
* Mozilla Firefox 111.0
|
||||
* WebKit 16.4
|
||||
|
||||
This version was also tested against the following stable channels:
|
||||
|
||||
* Google Chrome 111
|
||||
* Microsoft Edge 111
|
||||
|
||||
|
||||
## Version 1.31
|
||||
|
||||
### New APIs
|
||||
|
|
|
|||
|
|
@ -4,6 +4,26 @@ title: "Release notes"
|
|||
toc_max_heading_level: 2
|
||||
---
|
||||
|
||||
## Version 1.32
|
||||
|
||||
### New APIs
|
||||
|
||||
- New options [`option: updateMode`] and [`option: updateContent`] in [`method: Page.routeFromHAR`] and [`method: BrowserContext.routeFromHAR`].
|
||||
- Chaining existing locator objects, see [locator docs](./locators.md#chaining-locators) for details.
|
||||
- New option [`option: name`] in method [`method: Tracing.startChunk`].
|
||||
|
||||
### Browser Versions
|
||||
|
||||
* Chromium 112.0.5615.29
|
||||
* Mozilla Firefox 111.0
|
||||
* WebKit 16.4
|
||||
|
||||
This version was also tested against the following stable channels:
|
||||
|
||||
* Google Chrome 111
|
||||
* Microsoft Edge 111
|
||||
|
||||
|
||||
## Version 1.31
|
||||
|
||||
### New APIs
|
||||
|
|
|
|||
|
|
@ -6,6 +6,47 @@ toc_max_heading_level: 2
|
|||
|
||||
import LiteYouTube from '@site/src/components/LiteYouTube';
|
||||
|
||||
## Version 1.32
|
||||
|
||||
### Introducing UI Mode (preview)
|
||||
|
||||
New UI Mode lets you explore, run and debug tests. Comes with a built-in watch mode.
|
||||
|
||||

|
||||
|
||||
Engage with a new flag `--ui`:
|
||||
|
||||
```sh
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
### New APIs
|
||||
|
||||
- New options [`option: updateMode`] and [`option: updateContent`] in [`method: Page.routeFromHAR`] and [`method: BrowserContext.routeFromHAR`].
|
||||
- Chaining existing locator objects, see [locator docs](./locators.md#chaining-locators) for details.
|
||||
- New property [`property: TestInfo.testId`].
|
||||
- New option [`option: name`] in method [`method: Tracing.startChunk`].
|
||||
|
||||
|
||||
### ⚠️ Breaking change in component tests
|
||||
|
||||
Note: **component tests only**, does not affect end-to-end tests.
|
||||
|
||||
* `@playwright/experimental-ct-react` now supports **React 18 only**.
|
||||
* If you're running component tests with React 16 or 17, please replace
|
||||
`@playwright/experimental-ct-react` with `@playwright/experimental-ct-react17`.
|
||||
|
||||
### Browser Versions
|
||||
|
||||
* Chromium 112.0.5615.29
|
||||
* Mozilla Firefox 111.0
|
||||
* WebKit 16.4
|
||||
|
||||
This version was also tested against the following stable channels:
|
||||
|
||||
* Google Chrome 111
|
||||
* Microsoft Edge 111
|
||||
|
||||
## Version 1.31
|
||||
|
||||
<LiteYouTube
|
||||
|
|
@ -433,7 +474,7 @@ This version was also tested against the following stable channels:
|
|||
|
||||
### Announcements
|
||||
|
||||
* 🎁 We now ship Ubuntu 22.04 Jammy Jellyfish docker image: `mcr.microsoft.com/playwright:v1.32.0-jammy`.
|
||||
* 🎁 We now ship Ubuntu 22.04 Jammy Jellyfish docker image: `mcr.microsoft.com/playwright:v1.32.3-jammy`.
|
||||
* 🪦 This is the last release with macOS 10.15 support (deprecated as of 1.21).
|
||||
* 🪦 This is the last release with Node.js 12 support, we recommend upgrading to Node.js LTS (16).
|
||||
* ⚠️ Ubuntu 18 is now deprecated and will not be supported as of Dec 2022.
|
||||
|
|
@ -684,7 +725,7 @@ Read more about [component testing with Playwright](./test-components).
|
|||
}
|
||||
});
|
||||
```
|
||||
* Playwright now runs on Ubuntu 22 amd64 and Ubuntu 22 arm64. We also publish new docker image `mcr.microsoft.com/playwright:v1.32.0-jammy`.
|
||||
* Playwright now runs on Ubuntu 22 amd64 and Ubuntu 22 arm64. We also publish new docker image `mcr.microsoft.com/playwright:v1.32.3-jammy`.
|
||||
|
||||
### ⚠️ Breaking Changes ⚠️
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,27 @@ title: "Release notes"
|
|||
toc_max_heading_level: 2
|
||||
---
|
||||
|
||||
## Version 1.32
|
||||
|
||||
### New APIs
|
||||
|
||||
- Custom expect message, see [test assertions documentation](./test-assertions.md#custom-expect-message).
|
||||
- New options [`option: updateMode`] and [`option: updateContent`] in [`method: Page.routeFromHAR`] and [`method: BrowserContext.routeFromHAR`].
|
||||
- Chaining existing locator objects, see [locator docs](./locators.md#chaining-locators) for details.
|
||||
- New option [`option: name`] in method [`method: Tracing.startChunk`].
|
||||
|
||||
### Browser Versions
|
||||
|
||||
* Chromium 112.0.5615.29
|
||||
* Mozilla Firefox 111.0
|
||||
* WebKit 16.4
|
||||
|
||||
This version was also tested against the following stable channels:
|
||||
|
||||
* Google Chrome 111
|
||||
* Microsoft Edge 111
|
||||
|
||||
|
||||
## Version 1.31
|
||||
|
||||
### New APIs
|
||||
|
|
@ -237,7 +258,7 @@ This version was also tested against the following stable channels:
|
|||
|
||||
### Announcements
|
||||
|
||||
* 🎁 We now ship Ubuntu 22.04 Jammy Jellyfish docker image: `mcr.microsoft.com/playwright/python:v1.32.0-jammy`.
|
||||
* 🎁 We now ship Ubuntu 22.04 Jammy Jellyfish docker image: `mcr.microsoft.com/playwright/python:v1.32.3-jammy`.
|
||||
* 🪦 This is the last release with macOS 10.15 support (deprecated as of 1.21).
|
||||
* ⚠️ Ubuntu 18 is now deprecated and will not be supported as of Dec 2022.
|
||||
|
||||
|
|
|
|||
|
|
@ -595,7 +595,7 @@ export default defineConfig({
|
|||
- type: ?<[Object]|[Array]<[Object]>>
|
||||
- `command` <[string]> Shell command to start. For example `npm run start`..
|
||||
- `port` ?<[int]> The port that your http server is expected to appear on. It does wait until it accepts connections. Exactly one of `port` or `url` is required.
|
||||
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Exactly one of `port` or `url` is required.
|
||||
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Exactly one of `port` or `url` is required.
|
||||
- `ignoreHTTPSErrors` ?<[boolean]> Whether to ignore HTTPS errors when fetching the `url`. Defaults to `false`.
|
||||
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
|
||||
- `reuseExistingServer` ?<[boolean]> If true, it will re-use an existing server on the `port` or `url` when available. If no server is running on that `port` or `url`, it will run the command to start a new server. If `false`, it will throw if an existing process is listening on the `port` or `url`. This should be commonly set to `!process.env.CI` to allow the local dev server when running tests locally.
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export default defineConfig({
|
|||
```
|
||||
## Dependencies
|
||||
|
||||
Dependencies are a list of projects that need to run before the tests in another project run. They can be useful for configuring the global setup actions so that one project depends on this running first. Using dependencies allows global setup to produce traces and other artifacts.
|
||||
Dependencies are a list of projects that need to run before the tests in another project run. They can be useful for configuring the global setup actions so that one project depends on this running first. When using project dependencies, [test reporters](./test-reporters.md) will show the setup tests and the [trace viewer](/trace-viewer.md) will record traces of the setup. You can use the inspector to inspect the DOM snapshot of the trace of your setup tests and you can also use [fixtures](./test-fixtures.md) inside your setup.
|
||||
|
||||
In this example the chromium, firefox and webkit projects depend on the setup project.
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ export default defineConfig({
|
|||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /global.setup\.ts/,
|
||||
testMatch: '**/*.setup.ts',
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
|
|
@ -185,6 +185,28 @@ export default defineConfig({
|
|||
});
|
||||
```
|
||||
|
||||
### Running Sequence
|
||||
|
||||
When working with tests that have a dependency, the dependency will always run first and once all tests from this project have passed, then the other projects will run in parallel.
|
||||
|
||||
Running order:
|
||||
1. Tests in 'setup' project run
|
||||
|
||||
2. Tests in 'chromium', 'webkit' and 'firefox' projects run in parallel
|
||||
|
||||
<img width="70%" style={{display: 'flex', margin: 'auto'}} alt="chromium, webkit and firefox projects depend on setup project" loading="lazy" src="https://user-images.githubusercontent.com/13063165/225937080-327b1e63-431f-40e0-90d7-35f21d7a92cb.jpg" />
|
||||
|
||||
If there are more than one dependency then these project dependencies will be run first and in parallel. If the tests from a dependency fails then the tests that rely on this project will not be run.
|
||||
|
||||
Running order:
|
||||
1. Tests in 'Browser Login' and 'DataBase' projects run in parallel
|
||||
- 'Browser Login' passes
|
||||
- ❌ 'DataBase' fails!
|
||||
|
||||
1. “e2e tests” is not run!
|
||||
|
||||
<img width="70%" style={{display: 'flex', margin: 'auto'}} alt="Browser login project is blue, database is red and e2e tests relies on both" loading="lazy" src="https://user-images.githubusercontent.com/13063165/225938262-33c1b78f-f092-4762-a478-7f8cbc1e3b21.jpg" />
|
||||
|
||||
## Custom project parameters
|
||||
|
||||
Projects can be also used to parametrize tests with your custom configuration - take a look at [this separate guide](./test-parameterize.md#parameterized-projects).
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ The snapshot name `example-test-1-chromium-darwin.png` consists of a few parts:
|
|||
If you are not on the same operating system as your CI system, you can use Docker to generate/update the screenshots:
|
||||
|
||||
```bash
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.32.0-focal /bin/bash
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.32.3-focal /bin/bash
|
||||
npm install
|
||||
npx playwright test --update-snapshots
|
||||
```
|
||||
|
|
|
|||
72
package-lock.json
generated
72
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "playwright-internal",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "playwright-internal",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
|
@ -5955,11 +5955,11 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright": {
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -5969,11 +5969,11 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright-chromium": {
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -5983,7 +5983,7 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright-core": {
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -5994,10 +5994,10 @@
|
|||
},
|
||||
"packages/playwright-ct-react": {
|
||||
"name": "@playwright/experimental-ct-react",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
|
|
@ -6010,10 +6010,10 @@
|
|||
},
|
||||
"packages/playwright-ct-react17": {
|
||||
"name": "@playwright/experimental-ct-react17",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
|
|
@ -6026,10 +6026,10 @@
|
|||
},
|
||||
"packages/playwright-ct-solid": {
|
||||
"name": "@playwright/experimental-ct-solid",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"vite": "^4.1.1",
|
||||
"vite-plugin-solid": "^2.5.0"
|
||||
},
|
||||
|
|
@ -6045,10 +6045,10 @@
|
|||
},
|
||||
"packages/playwright-ct-svelte": {
|
||||
"name": "@playwright/experimental-ct-svelte",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^2.0.2",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
|
|
@ -6084,10 +6084,10 @@
|
|||
},
|
||||
"packages/playwright-ct-vue": {
|
||||
"name": "@playwright/experimental-ct-vue",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
|
|
@ -6136,10 +6136,10 @@
|
|||
},
|
||||
"packages/playwright-ct-vue2": {
|
||||
"name": "@playwright/experimental-ct-vue2",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-vue2": "^2.2.0",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
|
|
@ -6154,11 +6154,11 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright-firefox": {
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -6169,11 +6169,11 @@
|
|||
},
|
||||
"packages/playwright-test": {
|
||||
"name": "@playwright/test",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -6186,11 +6186,11 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright-webkit": {
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -6943,7 +6943,7 @@
|
|||
"@playwright/experimental-ct-react": {
|
||||
"version": "file:packages/playwright-ct-react",
|
||||
"requires": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"vite": "^4.1.1"
|
||||
}
|
||||
|
|
@ -6951,7 +6951,7 @@
|
|||
"@playwright/experimental-ct-react17": {
|
||||
"version": "file:packages/playwright-ct-react17",
|
||||
"requires": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"vite": "^4.1.1"
|
||||
}
|
||||
|
|
@ -6959,7 +6959,7 @@
|
|||
"@playwright/experimental-ct-solid": {
|
||||
"version": "file:packages/playwright-ct-solid",
|
||||
"requires": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"solid-js": "^1.6.10",
|
||||
"vite": "^4.1.1",
|
||||
"vite-plugin-solid": "^2.5.0"
|
||||
|
|
@ -6968,7 +6968,7 @@
|
|||
"@playwright/experimental-ct-svelte": {
|
||||
"version": "file:packages/playwright-ct-svelte",
|
||||
"requires": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^2.0.2",
|
||||
"svelte": "^3.55.1",
|
||||
"vite": "^4.1.1"
|
||||
|
|
@ -6992,7 +6992,7 @@
|
|||
"@playwright/experimental-ct-vue": {
|
||||
"version": "file:packages/playwright-ct-vue",
|
||||
"requires": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
|
|
@ -7027,7 +7027,7 @@
|
|||
"@playwright/experimental-ct-vue2": {
|
||||
"version": "file:packages/playwright-ct-vue2",
|
||||
"requires": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-vue2": "^2.2.0",
|
||||
"vite": "^4.1.1",
|
||||
"vue": "^2.7.14"
|
||||
|
|
@ -7038,7 +7038,7 @@
|
|||
"requires": {
|
||||
"@types/node": "*",
|
||||
"fsevents": "2.3.2",
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
},
|
||||
"@sindresorhus/is": {
|
||||
|
|
@ -9185,13 +9185,13 @@
|
|||
"playwright": {
|
||||
"version": "file:packages/playwright",
|
||||
"requires": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
},
|
||||
"playwright-chromium": {
|
||||
"version": "file:packages/playwright-chromium",
|
||||
"requires": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
},
|
||||
"playwright-core": {
|
||||
|
|
@ -9200,13 +9200,13 @@
|
|||
"playwright-firefox": {
|
||||
"version": "file:packages/playwright-firefox",
|
||||
"requires": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
},
|
||||
"playwright-webkit": {
|
||||
"version": "file:packages/playwright-webkit",
|
||||
"requires": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "playwright-internal",
|
||||
"private": true,
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright-chromium",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "A high-level API to automate Chromium",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -28,6 +28,6 @@
|
|||
"install": "node install.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright-core",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ export class PlaywrightConnection {
|
|||
}
|
||||
|
||||
private async _createOwnedSocksProxy(playwright: Playwright): Promise<SocksProxy | undefined> {
|
||||
if (!this._options.socksProxyPattern)
|
||||
return;
|
||||
const socksProxy = new SocksProxy();
|
||||
socksProxy.setPattern(this._options.socksProxyPattern);
|
||||
playwright.options.socksProxyPort = await socksProxy.listen(0);
|
||||
|
|
@ -280,4 +282,5 @@ const defaultLaunchOptions: LaunchOptions = {
|
|||
|
||||
const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [
|
||||
'headless',
|
||||
'tracesDir',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ export async function syncLocalStorageWithSettings(page: Page, appName: string)
|
|||
const settings = await fs.promises.readFile(settingsFile, 'utf-8').catch(() => ('{}'));
|
||||
await page.addInitScript(
|
||||
`(${String((settings: any) => {
|
||||
// iframes w/ snapshots, etc.
|
||||
if (location && location.protocol === 'data:')
|
||||
return;
|
||||
Object.entries(settings).map(([k, v]) => localStorage[k] = v);
|
||||
(window as any).saveSettings = () => {
|
||||
(window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage }));
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ export class DispatcherConnection {
|
|||
const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined;
|
||||
const callMetadata: CallMetadata = {
|
||||
id: `call@${id}`,
|
||||
wallTime: validMetadata.wallTime,
|
||||
wallTime: validMetadata.wallTime || Date.now(),
|
||||
location: validMetadata.location,
|
||||
apiName: validMetadata.apiName,
|
||||
internal: validMetadata.internal,
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export function serverSideCallMetadata(): CallMetadata {
|
|||
id: '',
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
wallTime: Date.now(),
|
||||
type: 'Internal',
|
||||
method: '',
|
||||
params: {},
|
||||
|
|
|
|||
|
|
@ -574,6 +574,7 @@ class ContextRecorder extends EventEmitter {
|
|||
frameId: frame.guid,
|
||||
startTime: monotonicTime(),
|
||||
endTime: 0,
|
||||
wallTime: Date.now(),
|
||||
type: 'Frame',
|
||||
method: action,
|
||||
params,
|
||||
|
|
|
|||
|
|
@ -128,8 +128,8 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
|
|||
channel: findChromiumChannel(sdkLanguage),
|
||||
args,
|
||||
noDefaultViewport: true,
|
||||
colorScheme: 'no-override',
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
colorScheme: 'no-override',
|
||||
headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed),
|
||||
useWebSocket: !!process.env.PWTEST_RECORDER_PORT,
|
||||
handleSIGINT,
|
||||
|
|
|
|||
|
|
@ -261,8 +261,12 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
|||
return {};
|
||||
|
||||
// Network file survives across chunks, make a snapshot before returning the resulting entries.
|
||||
const suffix = state.chunkOrdinal ? `-${state.chunkOrdinal}` : ``;
|
||||
const networkFile = path.join(state.tracesDir, state.traceName + `${suffix}.network`);
|
||||
// We should pick a name starting with "traceName" and ending with .network.
|
||||
// Something like <traceName>someSuffixHere.network.
|
||||
// However, this name must not clash with any other "traceName".network in the same tracesDir.
|
||||
// We can use <traceName>-<guid>.network, but "-pwnetcopy-0" suffix is more readable
|
||||
// and makes it easier to debug future issues.
|
||||
const networkFile = path.join(state.tracesDir, state.traceName + `-pwnetcopy-${state.chunkOrdinal}.network`);
|
||||
await fs.promises.copyFile(state.networkFile, networkFile);
|
||||
|
||||
const entries: NameValue[] = [];
|
||||
|
|
@ -489,7 +493,7 @@ function createBeforeActionTraceEvent(metadata: CallMetadata): trace.BeforeActio
|
|||
class: metadata.type,
|
||||
method: metadata.method,
|
||||
params: metadata.params,
|
||||
wallTime: metadata.wallTime || Date.now(),
|
||||
wallTime: metadata.wallTime,
|
||||
pageId: metadata.pageId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
|
|||
channel: findChromiumChannel(traceViewerPlaywright.options.sdkLanguage),
|
||||
args,
|
||||
noDefaultViewport: true,
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
headless,
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
colorScheme: 'no-override',
|
||||
useWebSocket: isUnderTest(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco
|
|||
const requestCallback = (res: http.IncomingMessage) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
if (statusCode >= 300 && statusCode < 400 && res.headers.location)
|
||||
httpRequest({ ...params, url: res.headers.location }, onResponse, onError);
|
||||
httpRequest({ ...params, url: new URL.URL(res.headers.location, params.url).toString() }, onResponse, onError);
|
||||
else
|
||||
onResponse(res);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-react",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "Playwright Component Testing for React",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-react17",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "Playwright Component Testing for React",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-solid",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "Playwright Component Testing for Solid",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"dependencies": {
|
||||
"vite": "^4.1.1",
|
||||
"vite-plugin-solid": "^2.5.0",
|
||||
"@playwright/test": "1.32.0-next"
|
||||
"@playwright/test": "1.32.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"solid-js": "^1.6.10"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-svelte",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "Playwright Component Testing for Svelte",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^2.0.2",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
|
|
|
|||
16
packages/playwright-ct-vue/index.d.ts
vendored
16
packages/playwright-ct-vue/index.d.ts
vendored
|
|
@ -41,14 +41,17 @@ type JsonObject = { [Key in string]?: JsonValue };
|
|||
|
||||
type Slot = string | string[];
|
||||
|
||||
export interface MountOptions<HooksConfig extends JsonObject, Props extends JsonObject> {
|
||||
export interface MountOptions<
|
||||
HooksConfig extends JsonObject,
|
||||
Props extends Record<string, unknown>
|
||||
> {
|
||||
props?: Props;
|
||||
slots?: Record<string, Slot> & { default?: Slot };
|
||||
on?: Record<string, Function>;
|
||||
hooksConfig?: HooksConfig;
|
||||
}
|
||||
|
||||
interface MountResult<Props extends JsonObject> extends Locator {
|
||||
interface MountResult<Props extends Record<string, unknown>> extends Locator {
|
||||
unmount(): Promise<void>;
|
||||
update(options: Omit<MountOptions<never, Props>, 'hooksConfig'>): Promise<void>;
|
||||
}
|
||||
|
|
@ -62,9 +65,12 @@ export interface ComponentFixtures {
|
|||
mount(component: JSX.Element): Promise<MountResultJsx>;
|
||||
mount<HooksConfig extends JsonObject>(
|
||||
component: any,
|
||||
options?: MountOptions<HooksConfig, JsonObject>
|
||||
): Promise<MountResult<JsonObject>>;
|
||||
mount<HooksConfig extends JsonObject, Props extends JsonObject = JsonObject>(
|
||||
options?: MountOptions<HooksConfig, Record<string, unknown>>
|
||||
): Promise<MountResult<Record<string, unknown>>>;
|
||||
mount<
|
||||
HooksConfig extends JsonObject,
|
||||
Props extends Record<string, unknown> = Record<string, unknown>
|
||||
>(
|
||||
component: any,
|
||||
options: MountOptions<HooksConfig, never> & { props: Props }
|
||||
): Promise<MountResult<Props>>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-vue",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "Playwright Component Testing for Vue",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
16
packages/playwright-ct-vue2/index.d.ts
vendored
16
packages/playwright-ct-vue2/index.d.ts
vendored
|
|
@ -41,14 +41,17 @@ type JsonObject = { [Key in string]?: JsonValue };
|
|||
|
||||
type Slot = string | string[];
|
||||
|
||||
export interface MountOptions<HooksConfig extends JsonObject, Props extends JsonObject> {
|
||||
export interface MountOptions<
|
||||
HooksConfig extends JsonObject,
|
||||
Props extends Record<string, unknown>
|
||||
> {
|
||||
props?: Props;
|
||||
slots?: Record<string, Slot> & { default?: Slot };
|
||||
on?: Record<string, Function>;
|
||||
hooksConfig?: HooksConfig;
|
||||
}
|
||||
|
||||
interface MountResult<Props extends JsonObject> extends Locator {
|
||||
interface MountResult<Props extends Record<string, unknown>> extends Locator {
|
||||
unmount(): Promise<void>;
|
||||
update(options: Omit<MountOptions<never, Props>, 'hooksConfig'>): Promise<void>;
|
||||
}
|
||||
|
|
@ -62,9 +65,12 @@ export interface ComponentFixtures {
|
|||
mount(component: JSX.Element): Promise<MountResultJsx>;
|
||||
mount<HooksConfig extends JsonObject>(
|
||||
component: any,
|
||||
options?: MountOptions<HooksConfig, JsonObject>
|
||||
): Promise<MountResult<JsonObject>>;
|
||||
mount<HooksConfig extends JsonObject, Props extends JsonObject = JsonObject>(
|
||||
options?: MountOptions<HooksConfig, Record<string, unknown>>
|
||||
): Promise<MountResult<Record<string, unknown>>>;
|
||||
mount<
|
||||
HooksConfig extends JsonObject,
|
||||
Props extends Record<string, unknown> = Record<string, unknown>
|
||||
>(
|
||||
component: any,
|
||||
options: MountOptions<HooksConfig, never> & { props: Props }
|
||||
): Promise<MountResult<Props>>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-vue2",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "Playwright Component Testing for Vue2",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.32.0-next",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@vitejs/plugin-vue2": "^2.2.0",
|
||||
"vite": "^4.1.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright-firefox",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "A high-level API to automate Firefox",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -28,6 +28,6 @@
|
|||
"install": "node install.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ This project incorporates components from the projects listed below. The origina
|
|||
- @babel/plugin-transform-modules-commonjs@7.19.6 (https://github.com/babel/babel)
|
||||
- @babel/plugin-transform-react-jsx@7.20.7 (https://github.com/babel/babel)
|
||||
- @babel/plugin-transform-typescript@7.20.2 (https://github.com/babel/babel)
|
||||
- @babel/preset-typescript@7.18.6 (https://github.com/babel/babel)
|
||||
- @babel/template@7.18.10 (https://github.com/babel/babel)
|
||||
- @babel/traverse@7.20.1 (https://github.com/babel/babel)
|
||||
- @babel/types@7.20.7 (https://github.com/babel/babel)
|
||||
|
|
@ -1727,6 +1728,33 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||
=========================================
|
||||
END OF @babel/plugin-transform-typescript@7.20.2 AND INFORMATION
|
||||
|
||||
%% @babel/preset-typescript@7.18.6 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014-present Sebastian McKenzie and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
=========================================
|
||||
END OF @babel/preset-typescript@7.18.6 AND INFORMATION
|
||||
|
||||
%% @babel/template@7.18.10 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
MIT License
|
||||
|
|
@ -4102,6 +4130,6 @@ END OF update-browserslist-db@1.0.10 AND INFORMATION
|
|||
|
||||
SUMMARY BEGIN HERE
|
||||
=========================================
|
||||
Total Packages: 142
|
||||
Total Packages: 143
|
||||
=========================================
|
||||
END OF SUMMARY
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.19.6",
|
||||
"@babel/plugin-transform-react-jsx": "^7.20.7",
|
||||
"@babel/plugin-transform-typescript": "^7.20.2"
|
||||
"@babel/preset-typescript": "^7.18.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/babel__code-frame": "^7.0.3",
|
||||
|
|
@ -724,6 +724,22 @@
|
|||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/preset-typescript": {
|
||||
"version": "7.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz",
|
||||
"integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.18.6",
|
||||
"@babel/helper-validator-option": "^7.18.6",
|
||||
"@babel/plugin-transform-typescript": "^7.18.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.18.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
|
||||
|
|
@ -1586,6 +1602,16 @@
|
|||
"@babel/plugin-syntax-typescript": "^7.20.0"
|
||||
}
|
||||
},
|
||||
"@babel/preset-typescript": {
|
||||
"version": "7.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz",
|
||||
"integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==",
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.18.6",
|
||||
"@babel/helper-validator-option": "^7.18.6",
|
||||
"@babel/plugin-transform-typescript": "^7.18.6"
|
||||
}
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.18.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
"@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.19.6",
|
||||
"@babel/plugin-transform-react-jsx": "^7.20.7",
|
||||
"@babel/plugin-transform-typescript": "^7.20.2"
|
||||
"@babel/preset-typescript": "^7.18.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/babel__code-frame": "^7.0.3",
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ export function babelTransform(filename: string, isTypeScript: boolean, isModule
|
|||
|
||||
if (isTypeScript) {
|
||||
plugins.push(
|
||||
[require('@babel/plugin-proposal-class-properties')],
|
||||
[require('@babel/plugin-proposal-numeric-separator')],
|
||||
[require('@babel/plugin-proposal-logical-assignment-operators')],
|
||||
[require('@babel/plugin-proposal-nullish-coalescing-operator')],
|
||||
[require('@babel/plugin-proposal-optional-chaining')],
|
||||
[require('@babel/plugin-proposal-private-methods')],
|
||||
[require('@babel/plugin-syntax-json-strings')],
|
||||
[require('@babel/plugin-syntax-optional-catch-binding')],
|
||||
[require('@babel/plugin-syntax-async-generators')],
|
||||
|
|
@ -54,10 +56,7 @@ export function babelTransform(filename: string, isTypeScript: boolean, isModule
|
|||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
[require('@babel/plugin-transform-typescript'), { onlyRemoveTypeImports: false, allowDeclareFields: true, isTSX: true }],
|
||||
[require('@babel/plugin-proposal-class-properties')],
|
||||
[require('@babel/plugin-proposal-private-methods')],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +83,9 @@ export function babelTransform(filename: string, isTypeScript: boolean, isModule
|
|||
// breaks playwright evaluates.
|
||||
setPublicClassFields: true,
|
||||
},
|
||||
presets: [],
|
||||
presets: [
|
||||
[require('@babel/preset-typescript'), { onlyRemoveTypeImports: false }],
|
||||
],
|
||||
plugins,
|
||||
sourceMaps: 'both',
|
||||
} as babel.TransformOptions)!;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/test",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import type { FullResult } from '../reporter';
|
|||
|
||||
export function addTestCommands(program: Command) {
|
||||
addTestCommand(program);
|
||||
addUICommand(program);
|
||||
addShowReportCommand(program);
|
||||
addListFilesCommand(program);
|
||||
}
|
||||
|
|
@ -38,7 +37,7 @@ export function addTestCommands(program: Command) {
|
|||
function addTestCommand(program: Command) {
|
||||
const command = program.command('test [test-filter...]');
|
||||
command.description('run tests with Playwright Test');
|
||||
const options = [...sharedOptions, ...testOnlyOptions].sort((a, b) => a[0].replace(/-/g, '').localeCompare(b[0].replace(/-/g, '')));
|
||||
const options = testOptions.sort((a, b) => a[0].replace(/-/g, '').localeCompare(b[0].replace(/-/g, '')));
|
||||
options.forEach(([name, description]) => command.option(name, description));
|
||||
command.action(async (args, opts) => {
|
||||
try {
|
||||
|
|
@ -59,30 +58,6 @@ Examples:
|
|||
$ npx playwright test --project=webkit`);
|
||||
}
|
||||
|
||||
function addUICommand(program: Command) {
|
||||
const command = program.command('ui [test-filter...]');
|
||||
command.description('open Playwright Test interactive UI');
|
||||
sharedOptions.forEach(([name, description]) => command.option(name, description));
|
||||
command.action(async (args, opts) => {
|
||||
try {
|
||||
opts.ui = true;
|
||||
await runTests(args, opts);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
command.addHelpText('afterAll', `
|
||||
Arguments [test-filter...]:
|
||||
Pass arguments to filter test files. Each argument is treated as a regular expression. Matching is performed against the absolute file paths.
|
||||
|
||||
Examples:
|
||||
$ npx playwright ui my.spec.ts
|
||||
$ npx playwright ui some.spec.ts:42
|
||||
$ npx playwright ui --headed
|
||||
$ npx playwright ui --project=webkit`);
|
||||
}
|
||||
|
||||
function addListFilesCommand(program: Command) {
|
||||
const command = program.command('list-files [file-filter...]', { hidden: true });
|
||||
command.description('List files with Playwright Test tests');
|
||||
|
|
@ -259,35 +234,32 @@ function restartWithExperimentalTsEsm(configFile: string | null): boolean {
|
|||
|
||||
const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'retain-on-failure'];
|
||||
|
||||
const sharedOptions: [string, string][] = [
|
||||
['--headed', `Run tests in headed browsers (default: headless)`],
|
||||
['-c, --config <file>', `Configuration file, or a test directory with optional ${kDefaultConfigFiles.map(file => `"${file}"`).join('/')}`],
|
||||
['--fully-parallel', `Run all tests in parallel (default: false)`],
|
||||
['--ignore-snapshots', `Ignore screenshot and snapshot expectations`],
|
||||
['-j, --workers <workers>', `Number of concurrent workers or percentage of logical CPU cores, use 1 to run in a single worker (default: 50%)`],
|
||||
['--max-failures <N>', `Stop after the first N failures`],
|
||||
['--no-deps', 'Do not run project dependencies'],
|
||||
['--output <dir>', `Folder for output artifacts (default: "test-results")`],
|
||||
['--quiet', `Suppress stdio`],
|
||||
['--reporter <reporter>', `Reporter to use, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} (default: "${baseFullConfig.reporter[0]}")`],
|
||||
['--project <project-name...>', `Only run tests from the specified list of projects (default: run all projects)`],
|
||||
['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`],
|
||||
['-x', `Stop after the first failure`],
|
||||
];
|
||||
|
||||
const testOnlyOptions: [string, string][] = [
|
||||
const testOptions: [string, string][] = [
|
||||
['--browser <browser>', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`],
|
||||
['-c, --config <file>', `Configuration file, or a test directory with optional ${kDefaultConfigFiles.map(file => `"${file}"`).join('/')}`],
|
||||
['--debug', `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options`],
|
||||
['--forbid-only', `Fail if test.only is called (default: false)`],
|
||||
['--fully-parallel', `Run all tests in parallel (default: false)`],
|
||||
['--global-timeout <timeout>', `Maximum time this test suite can run in milliseconds (default: unlimited)`],
|
||||
['-g, --grep <grep>', `Only run tests matching this regular expression (default: ".*")`],
|
||||
['-gv, --grep-invert <grep>', `Only run tests that do not match this regular expression`],
|
||||
['--headed', `Run tests in headed browsers (default: headless)`],
|
||||
['--ignore-snapshots', `Ignore screenshot and snapshot expectations`],
|
||||
['--list', `Collect all the tests and report them, but do not run`],
|
||||
['--max-failures <N>', `Stop after the first N failures`],
|
||||
['--no-deps', 'Do not run project dependencies'],
|
||||
['--output <dir>', `Folder for output artifacts (default: "test-results")`],
|
||||
['--pass-with-no-tests', `Makes test run succeed even if no tests were found`],
|
||||
['--project <project-name...>', `Only run tests from the specified list of projects (default: run all projects)`],
|
||||
['--quiet', `Suppress stdio`],
|
||||
['--repeat-each <N>', `Run each test N times (default: 1)`],
|
||||
['--reporter <reporter>', `Reporter to use, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} (default: "${baseFullConfig.reporter[0]}")`],
|
||||
['--retries <retries>', `Maximum retry count for flaky tests, zero for no retries (default: no retries)`],
|
||||
['--shard <shard>', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`],
|
||||
['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`],
|
||||
['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`],
|
||||
['--ui', `Run tests in interactive UI mode`],
|
||||
['-u, --update-snapshots', `Update snapshots with actual results (default: only create missing snapshots)`],
|
||||
['-j, --workers <workers>', `Number of concurrent workers or percentage of logical CPU cores, use 1 to run in a single worker (default: 50%)`],
|
||||
['-x', `Stop after the first failure`],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
|
|||
|
||||
_snapshotSuffix: [process.platform, { scope: 'worker' }],
|
||||
|
||||
_setupContextOptionsAndArtifacts: [async ({ playwright, _snapshotSuffix, _combinedContextOptions, _artifactsDir, trace, screenshot, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => {
|
||||
_setupContextOptionsAndArtifacts: [async ({ playwright, _contextReuseMode, _snapshotSuffix, _combinedContextOptions, _artifactsDir, trace, screenshot, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => {
|
||||
if (testIdAttribute)
|
||||
playwrightLibrary.selectors.setTestIdAttribute(testIdAttribute);
|
||||
testInfo.snapshotSuffix = _snapshotSuffix;
|
||||
|
|
@ -251,7 +251,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
|
|||
const traceMode = normalizeTraceMode(trace);
|
||||
const defaultTraceOptions = { screenshots: true, snapshots: true, sources: true };
|
||||
const traceOptions = typeof trace === 'string' ? defaultTraceOptions : { ...defaultTraceOptions, ...trace, mode: undefined };
|
||||
const captureTrace = shouldCaptureTrace(traceMode, testInfo);
|
||||
const captureTrace = shouldCaptureTrace(traceMode, testInfo) && !process.env.PW_TEST_DISABLE_TRACING;
|
||||
const temporaryTraceFiles: string[] = [];
|
||||
const temporaryScreenshots: string[] = [];
|
||||
const testInfoImpl = testInfo as TestInfoImpl;
|
||||
|
|
@ -603,7 +603,7 @@ type ParsedStackTrace = {
|
|||
apiName: string;
|
||||
};
|
||||
|
||||
export function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode {
|
||||
function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode {
|
||||
if (!video)
|
||||
return 'off';
|
||||
let videoMode = typeof video === 'string' ? video : video.mode;
|
||||
|
|
@ -616,7 +616,7 @@ function shouldCaptureVideo(videoMode: VideoMode, testInfo: TestInfo) {
|
|||
return (videoMode === 'on' || videoMode === 'retain-on-failure' || (videoMode === 'on-first-retry' && testInfo.retry === 1));
|
||||
}
|
||||
|
||||
export function normalizeTraceMode(trace: TraceMode | 'retry-with-trace' | { mode: TraceMode } | undefined): TraceMode {
|
||||
function normalizeTraceMode(trace: TraceMode | 'retry-with-trace' | { mode: TraceMode } | undefined): TraceMode {
|
||||
if (!trace)
|
||||
return 'off';
|
||||
let traceMode = typeof trace === 'string' ? trace : trace.mode;
|
||||
|
|
|
|||
|
|
@ -149,7 +149,6 @@ export class TeleReporterReceiver {
|
|||
}
|
||||
|
||||
private _onBegin(config: JsonConfig, projects: JsonProject[]) {
|
||||
const removeMissing = config.listOnly;
|
||||
for (const project of projects) {
|
||||
let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.id === project.id);
|
||||
if (!projectSuite) {
|
||||
|
|
@ -159,7 +158,24 @@ export class TeleReporterReceiver {
|
|||
}
|
||||
const p = this._parseProject(project);
|
||||
projectSuite.project = () => p;
|
||||
this._mergeSuitesInto(project.suites, projectSuite, removeMissing);
|
||||
this._mergeSuitesInto(project.suites, projectSuite);
|
||||
|
||||
// Remove deleted tests when listing. Empty suites will be auto-filtered
|
||||
// in the UI layer.
|
||||
if (config.listOnly) {
|
||||
const testIds = new Set<string>();
|
||||
const collectIds = (suite: JsonSuite) => {
|
||||
suite.tests.map(t => t.testId).forEach(testId => testIds.add(testId));
|
||||
suite.suites.forEach(collectIds);
|
||||
};
|
||||
project.suites.forEach(collectIds);
|
||||
|
||||
const filterTests = (suite: TeleSuite) => {
|
||||
suite.tests = suite.tests.filter(t => testIds.has(t.id));
|
||||
suite.suites.forEach(filterTests);
|
||||
};
|
||||
filterTests(projectSuite);
|
||||
}
|
||||
}
|
||||
this._reporter.onBegin?.(this._parseConfig(config), this._rootSuite);
|
||||
}
|
||||
|
|
@ -171,6 +187,7 @@ export class TeleReporterReceiver {
|
|||
testResult.workerIndex = payload.workerIndex;
|
||||
testResult.parallelIndex = payload.parallelIndex;
|
||||
testResult.startTime = new Date(payload.startTime);
|
||||
testResult.statusEx = 'running';
|
||||
this._reporter.onTestBegin?.(test, testResult);
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +196,7 @@ export class TeleReporterReceiver {
|
|||
const result = test.resultsMap.get(payload.id)!;
|
||||
result.duration = payload.duration;
|
||||
result.status = payload.status;
|
||||
result.statusEx = payload.status;
|
||||
result.errors = payload.errors;
|
||||
result.attachments = payload.attachments;
|
||||
this._reporter.onTestEnd?.(test, result);
|
||||
|
|
@ -260,7 +278,7 @@ export class TeleReporterReceiver {
|
|||
};
|
||||
}
|
||||
|
||||
private _mergeSuitesInto(jsonSuites: JsonSuite[], parent: TeleSuite, removeMissing: boolean) {
|
||||
private _mergeSuitesInto(jsonSuites: JsonSuite[], parent: TeleSuite) {
|
||||
for (const jsonSuite of jsonSuites) {
|
||||
let targetSuite = parent.suites.find(s => s.title === jsonSuite.title);
|
||||
if (!targetSuite) {
|
||||
|
|
@ -271,16 +289,12 @@ export class TeleReporterReceiver {
|
|||
targetSuite.location = jsonSuite.location;
|
||||
targetSuite._fileId = jsonSuite.fileId;
|
||||
targetSuite._parallelMode = jsonSuite.parallelMode;
|
||||
this._mergeSuitesInto(jsonSuite.suites, targetSuite, removeMissing);
|
||||
this._mergeTestsInto(jsonSuite.tests, targetSuite, removeMissing);
|
||||
}
|
||||
if (removeMissing) {
|
||||
const suiteMap = new Map(parent.suites.map(p => [p.title, p]));
|
||||
parent.suites = jsonSuites.map(s => suiteMap.get(s.title)).filter(Boolean) as TeleSuite[];
|
||||
this._mergeSuitesInto(jsonSuite.suites, targetSuite);
|
||||
this._mergeTestsInto(jsonSuite.tests, targetSuite);
|
||||
}
|
||||
}
|
||||
|
||||
private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite, removeMissing: boolean) {
|
||||
private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) {
|
||||
for (const jsonTest of jsonTests) {
|
||||
let targetTest = parent.tests.find(s => s.title === jsonTest.title);
|
||||
if (!targetTest) {
|
||||
|
|
@ -291,16 +305,13 @@ export class TeleReporterReceiver {
|
|||
}
|
||||
this._updateTest(jsonTest, targetTest);
|
||||
}
|
||||
if (removeMissing) {
|
||||
const testMap = new Map(parent.tests.map(p => [p.title, p]));
|
||||
parent.tests = jsonTests.map(s => testMap.get(s.title)).filter(Boolean) as TeleTestCase[];
|
||||
}
|
||||
}
|
||||
|
||||
private _updateTest(payload: JsonTestCase, test: TeleTestCase): TeleTestCase {
|
||||
test.id = payload.testId;
|
||||
test.expectedStatus = payload.expectedStatus;
|
||||
test.timeout = payload.timeout;
|
||||
test.location = payload.location;
|
||||
test.annotations = payload.annotations;
|
||||
test.retries = payload.retries;
|
||||
return test;
|
||||
|
|
@ -355,7 +366,7 @@ export class TeleSuite implements SuitePrivate {
|
|||
export class TeleTestCase implements reporterTypes.TestCase {
|
||||
title: string;
|
||||
fn = () => {};
|
||||
results: reporterTypes.TestResult[] = [];
|
||||
results: TeleTestResult[] = [];
|
||||
location: Location;
|
||||
parent!: TeleSuite;
|
||||
|
||||
|
|
@ -401,7 +412,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
|
|||
this.resultsMap.clear();
|
||||
}
|
||||
|
||||
_createTestResult(id: string): reporterTypes.TestResult {
|
||||
_createTestResult(id: string): TeleTestResult {
|
||||
this._clearResults();
|
||||
const result: TeleTestResult = {
|
||||
retry: this.results.length,
|
||||
|
|
@ -413,6 +424,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
|
|||
stderr: [],
|
||||
attachments: [],
|
||||
status: 'skipped',
|
||||
statusEx: 'scheduled',
|
||||
steps: [],
|
||||
errors: [],
|
||||
stepMap: new Map(),
|
||||
|
|
@ -428,6 +440,7 @@ export class TeleTestCase implements reporterTypes.TestCase {
|
|||
export type TeleTestResult = reporterTypes.TestResult & {
|
||||
stepMap: Map<string, reporterTypes.TestStep>;
|
||||
stepStack: (reporterTypes.TestStep | reporterTypes.TestResult)[];
|
||||
statusEx: reporterTypes.TestResult['status'] | 'scheduled' | 'running';
|
||||
};
|
||||
|
||||
export type TeleFullProject = FullProject & { id: string };
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ import type { FrameExpectOptions } from 'playwright-core/lib/client/types';
|
|||
import { colors } from 'playwright-core/lib/utilsBundle';
|
||||
import type { Expect } from '../common/types';
|
||||
import { expectTypes, callLogText } from '../util';
|
||||
import { currentTestInfo } from '../common/globals';
|
||||
import type { TestInfoErrorState } from '../worker/testInfo';
|
||||
import { toBeTruthy } from './toBeTruthy';
|
||||
import { toEqual } from './toEqual';
|
||||
import { toExpectedTextValues, toMatchText } from './toMatchText';
|
||||
|
|
@ -328,22 +326,11 @@ export async function toPass(
|
|||
timeout?: number,
|
||||
} = {},
|
||||
) {
|
||||
const testInfo = currentTestInfo();
|
||||
|
||||
const timeout = options.timeout !== undefined ? options.timeout : 0;
|
||||
|
||||
// Soft expects might mark test as failing.
|
||||
// We want to revert this later if the matcher is actually passing.
|
||||
// See https://github.com/microsoft/playwright/issues/20437
|
||||
let testStateBeforeToPassMatcher: undefined|TestInfoErrorState;
|
||||
const result = await pollAgainstTimeout<Error|undefined>(async () => {
|
||||
try {
|
||||
if (testStateBeforeToPassMatcher && testInfo)
|
||||
testInfo._restoreErrorState(testStateBeforeToPassMatcher);
|
||||
testStateBeforeToPassMatcher = testInfo?._saveErrorState();
|
||||
await callback();
|
||||
if (testInfo && testStateBeforeToPassMatcher && testInfo.errors.length > testStateBeforeToPassMatcher.errors.length)
|
||||
return { continuePolling: !this.isNot, result: testInfo.errors[testInfo.errors.length - 1] };
|
||||
return { continuePolling: this.isNot, result: undefined };
|
||||
} catch (e) {
|
||||
return { continuePolling: !this.isNot, result: e };
|
||||
|
|
@ -361,7 +348,5 @@ export async function toPass(
|
|||
|
||||
return { message, pass: this.isNot };
|
||||
}
|
||||
if (testStateBeforeToPassMatcher && testInfo)
|
||||
testInfo._restoreErrorState(testStateBeforeToPassMatcher);
|
||||
return { pass: !this.isNot, message: () => '' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ export class BaseReporter implements Reporter {
|
|||
protected generateStartingMessage() {
|
||||
const jobs = Math.min(this.config.workers, this.config._internal.maxConcurrentTestGroups);
|
||||
const shardDetails = this.config.shard ? `, shard ${this.config.shard.current} of ${this.config.shard.total}` : '';
|
||||
if (!this.totalTestCount)
|
||||
return '';
|
||||
return '\n' + colors.dim('Running ') + this.totalTestCount + colors.dim(` test${this.totalTestCount !== 1 ? 's' : ''} using `) + jobs + colors.dim(` worker${jobs !== 1 ? 's' : ''}${shardDetails}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,8 +47,11 @@ class ListReporter extends BaseReporter {
|
|||
|
||||
override onBegin(config: FullConfig, suite: Suite) {
|
||||
super.onBegin(config, suite);
|
||||
console.log(this.generateStartingMessage());
|
||||
console.log();
|
||||
const startingMessage = this.generateStartingMessage();
|
||||
if (startingMessage) {
|
||||
console.log(startingMessage);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
onTestBegin(test: TestCase, result: TestResult) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { showTraceViewer } from 'playwright-core/lib/server';
|
|||
import type { Page } from 'playwright-core/lib/server/page';
|
||||
import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils';
|
||||
import type { FullResult } from '../../reporter';
|
||||
import { clearCompilationCache, dependenciesForTestFile } from '../common/compilationCache';
|
||||
import { clearCompilationCache, collectAffectedTestFiles, dependenciesForTestFile } from '../common/compilationCache';
|
||||
import type { FullConfigInternal } from '../common/types';
|
||||
import { Multiplexer } from '../reporters/multiplexer';
|
||||
import { TeleReporterEmitter } from '../reporters/teleEmitter';
|
||||
|
|
@ -28,13 +28,15 @@ import { createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForW
|
|||
import { chokidar } from '../utilsBundle';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import { open } from '../utilsBundle';
|
||||
import ListReporter from '../reporters/list';
|
||||
|
||||
class UIMode {
|
||||
private _config: FullConfigInternal;
|
||||
private _page!: Page;
|
||||
private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined;
|
||||
globalCleanup: (() => Promise<FullResult['status']>) | undefined;
|
||||
private _testWatcher: FSWatcher | undefined;
|
||||
private _globalWatcher: Watcher;
|
||||
private _testWatcher: Watcher;
|
||||
private _originalStderr: (buffer: string | Uint8Array) => void;
|
||||
|
||||
constructor(config: FullConfigInternal) {
|
||||
|
|
@ -56,28 +58,16 @@ class UIMode {
|
|||
config._internal.configCLIOverrides.use.trace = { mode: 'on', sources: false };
|
||||
|
||||
this._originalStderr = process.stderr.write.bind(process.stderr);
|
||||
this._installGlobalWatcher();
|
||||
}
|
||||
|
||||
private _installGlobalWatcher(): FSWatcher {
|
||||
const projectDirs = new Set<string>();
|
||||
for (const p of this._config.projects)
|
||||
projectDirs.add(p.testDir);
|
||||
let coalescingTimer: NodeJS.Timeout | undefined;
|
||||
const watcher = chokidar.watch([...projectDirs], { ignoreInitial: true, persistent: true }).on('all', async event => {
|
||||
if (event !== 'add' && event !== 'change' && event !== 'unlink')
|
||||
return;
|
||||
if (coalescingTimer)
|
||||
clearTimeout(coalescingTimer);
|
||||
coalescingTimer = setTimeout(() => {
|
||||
this._dispatchEvent({ method: 'listChanged' });
|
||||
}, 200);
|
||||
this._globalWatcher = new Watcher('deep', () => this._dispatchEvent({ method: 'listChanged' }));
|
||||
this._testWatcher = new Watcher('flat', events => {
|
||||
const collector = new Set<string>();
|
||||
events.forEach(f => collectAffectedTestFiles(f.file, collector));
|
||||
this._dispatchEvent({ method: 'testFilesChanged', params: { testFileNames: [...collector] } });
|
||||
});
|
||||
return watcher;
|
||||
}
|
||||
|
||||
async runGlobalSetup(): Promise<FullResult['status']> {
|
||||
const reporter = await createReporter(this._config, 'watch');
|
||||
const reporter = new Multiplexer([new ListReporter()]);
|
||||
const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter);
|
||||
reporter.onConfigure(this._config);
|
||||
const context: TaskRunnerState = {
|
||||
|
|
@ -86,6 +76,7 @@ class UIMode {
|
|||
phases: [],
|
||||
};
|
||||
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0);
|
||||
await reporter.onExit({ status });
|
||||
if (status !== 'passed') {
|
||||
await globalCleanup();
|
||||
return status;
|
||||
|
|
@ -96,14 +87,16 @@ class UIMode {
|
|||
|
||||
async showUI() {
|
||||
this._page = await showTraceViewer([], 'chromium', { app: 'watch.html', headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1' });
|
||||
process.stdout.write = (chunk: string | Buffer) => {
|
||||
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) });
|
||||
return true;
|
||||
};
|
||||
process.stderr.write = (chunk: string | Buffer) => {
|
||||
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) });
|
||||
return true;
|
||||
};
|
||||
if (!process.env.PWTEST_DEBUG) {
|
||||
process.stdout.write = (chunk: string | Buffer) => {
|
||||
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) });
|
||||
return true;
|
||||
};
|
||||
process.stderr.write = (chunk: string | Buffer) => {
|
||||
this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) });
|
||||
return true;
|
||||
};
|
||||
}
|
||||
const exitPromise = new ManualPromise();
|
||||
this._page.on('close', () => exitPromise.resolve());
|
||||
let queue = Promise.resolve();
|
||||
|
|
@ -159,7 +152,13 @@ class UIMode {
|
|||
const context: TaskRunnerState = { config: this._config, reporter, phases: [] };
|
||||
clearCompilationCache();
|
||||
reporter.onConfigure(this._config);
|
||||
await taskRunner.run(context, 0);
|
||||
const status = await taskRunner.run(context, 0);
|
||||
await reporter.onExit({ status });
|
||||
|
||||
const projectDirs = new Set<string>();
|
||||
for (const p of this._config.projects)
|
||||
projectDirs.add(p.testDir);
|
||||
this._globalWatcher.update([...projectDirs], false);
|
||||
}
|
||||
|
||||
private async _runTests(testIds: string[]) {
|
||||
|
|
@ -187,22 +186,12 @@ class UIMode {
|
|||
}
|
||||
|
||||
private async _watchFiles(fileNames: string[]) {
|
||||
if (this._testWatcher)
|
||||
await this._testWatcher.close();
|
||||
if (!fileNames.length)
|
||||
return;
|
||||
|
||||
const files = new Set<string>();
|
||||
for (const fileName of fileNames) {
|
||||
files.add(fileName);
|
||||
dependenciesForTestFile(fileName).forEach(file => files.add(file));
|
||||
}
|
||||
|
||||
this._testWatcher = chokidar.watch([...files], { ignoreInitial: true }).on('all', async (event, file) => {
|
||||
if (event !== 'add' && event !== 'change')
|
||||
return;
|
||||
this._dispatchEvent({ method: 'fileChanged', params: { fileName: file } });
|
||||
});
|
||||
this._testWatcher.update([...files], true);
|
||||
}
|
||||
|
||||
private async _stopTests() {
|
||||
|
|
@ -235,3 +224,54 @@ function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): Stdi
|
|||
return { type, buffer: chunk.toString('base64') };
|
||||
return { type, text: chunk };
|
||||
}
|
||||
|
||||
type FSEvent = { event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', file: string };
|
||||
|
||||
class Watcher {
|
||||
private _onChange: (events: FSEvent[]) => void;
|
||||
private _watchedFiles: string[] = [];
|
||||
private _collector: FSEvent[] = [];
|
||||
private _fsWatcher: FSWatcher | undefined;
|
||||
private _throttleTimer: NodeJS.Timeout | undefined;
|
||||
private _mode: 'flat' | 'deep';
|
||||
|
||||
constructor(mode: 'flat' | 'deep', onChange: (events: FSEvent[]) => void) {
|
||||
this._mode = mode;
|
||||
this._onChange = onChange;
|
||||
}
|
||||
|
||||
update(watchedFiles: string[], reportPending: boolean) {
|
||||
if (JSON.stringify(this._watchedFiles) === JSON.stringify(watchedFiles))
|
||||
return;
|
||||
|
||||
if (reportPending)
|
||||
this._reportEventsIfAny();
|
||||
|
||||
this._watchedFiles = watchedFiles;
|
||||
this._fsWatcher?.close().then(() => {});
|
||||
this._fsWatcher = undefined;
|
||||
this._collector.length = 0;
|
||||
clearTimeout(this._throttleTimer);
|
||||
this._throttleTimer = undefined;
|
||||
|
||||
if (!this._watchedFiles.length)
|
||||
return;
|
||||
|
||||
this._fsWatcher = chokidar.watch(watchedFiles, { ignoreInitial: true }).on('all', async (event, file) => {
|
||||
if (this._throttleTimer)
|
||||
clearTimeout(this._throttleTimer);
|
||||
if (this._mode === 'flat' && event !== 'add' && event !== 'change')
|
||||
return;
|
||||
if (this._mode === 'deep' && event !== 'add' && event !== 'change' && event !== 'unlink' && event !== 'addDir' && event !== 'unlinkDir')
|
||||
return;
|
||||
this._collector.push({ event, file });
|
||||
this._throttleTimer = setTimeout(() => this._reportEventsIfAny(), 250);
|
||||
});
|
||||
}
|
||||
|
||||
private _reportEventsIfAny() {
|
||||
if (this._collector.length)
|
||||
this._onChange(this._collector.slice());
|
||||
this._collector.length = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,6 @@ import type { Annotation, FullConfigInternal, FullProjectInternal, Location } fr
|
|||
import { getContainedPath, normalizeAndSaveAttachment, sanitizeForFilePath, serializeError, trimLongString } from '../util';
|
||||
import type * as trace from '@trace/trace';
|
||||
|
||||
export type TestInfoErrorState = {
|
||||
status: TestStatus,
|
||||
errors: TestInfoError[],
|
||||
hasHardError: boolean,
|
||||
};
|
||||
|
||||
interface TestStepInternal {
|
||||
complete(result: { error?: Error | TestInfoError }): void;
|
||||
title: string;
|
||||
|
|
@ -249,7 +243,6 @@ export class TestInfoImpl implements TestInfo {
|
|||
stepId,
|
||||
...data,
|
||||
location,
|
||||
wallTime: Date.now(),
|
||||
};
|
||||
this._onStepBegin(payload);
|
||||
return step;
|
||||
|
|
@ -269,20 +262,6 @@ export class TestInfoImpl implements TestInfo {
|
|||
this.errors.push(error);
|
||||
}
|
||||
|
||||
_saveErrorState(): TestInfoErrorState {
|
||||
return {
|
||||
hasHardError: this._hasHardError,
|
||||
status: this.status,
|
||||
errors: this.errors.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
_restoreErrorState(state: TestInfoErrorState) {
|
||||
this.status = state.status;
|
||||
this.errors = state.errors.slice();
|
||||
this._hasHardError = state.hasHardError;
|
||||
}
|
||||
|
||||
async _runAsStep<T>(cb: () => Promise<T>, stepInfo: Omit<TestStepInternal, 'complete' | 'wallTime'>): Promise<T> {
|
||||
const step = this._addStep({ ...stepInfo, wallTime: Date.now() });
|
||||
try {
|
||||
|
|
|
|||
3
packages/playwright-test/types/test.d.ts
vendored
3
packages/playwright-test/types/test.d.ts
vendored
|
|
@ -5887,7 +5887,8 @@ interface TestConfigWebServer {
|
|||
|
||||
/**
|
||||
* The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the
|
||||
* server is ready to accept connections. Exactly one of `port` or `url` is required.
|
||||
* server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is
|
||||
* checked. Exactly one of `port` or `url` is required.
|
||||
*/
|
||||
url?: string;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright-webkit",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "A high-level API to automate WebKit",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -28,6 +28,6 @@
|
|||
"install": "node install.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright",
|
||||
"version": "1.32.0-next",
|
||||
"version": "1.32.3",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"homepage": "https://playwright.dev",
|
||||
|
|
@ -28,6 +28,6 @@
|
|||
"install": "node install.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-core": "1.32.0-next"
|
||||
"playwright-core": "1.32.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export type CallMetadata = {
|
|||
// through the dispatcher, so is always excluded from inspector / tracing.
|
||||
isServerSide?: boolean;
|
||||
// Client wall time.
|
||||
wallTime?: number;
|
||||
wallTime: number;
|
||||
location?: { file: string, line?: number, column?: number };
|
||||
log: string[];
|
||||
error?: SerializedError;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"display": "browser",
|
||||
"start_url": "watch.html",
|
||||
"name": "Playwright Test",
|
||||
"short_name": "Trace Viewer",
|
||||
"short_name": "Playwright Test",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192x192.png",
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ export class SnapshotRenderer {
|
|||
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
|
||||
html = prefix + [
|
||||
'<style>*,*::before,*::after { visibility: hidden }</style>',
|
||||
`<style>*[__playwright_target__="${this._callId}"] { background-color: #6fa8dc7f; }</style>`,
|
||||
`<style>*[__playwright_target__="${this.snapshotName}"] { outline: 2px solid #006ab1 !important; background-color: #6fa8dc7f !important; }</style>`,
|
||||
`<style>*[__playwright_target__="${this._callId}"] { outline: 2px solid #006ab1 !important; background-color: #6fa8dc7f !important; }</style>`,
|
||||
`<script>${snapshotScript()}</script>`
|
||||
].join('') + html;
|
||||
|
||||
|
|
|
|||
|
|
@ -42,15 +42,13 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
|
|||
try {
|
||||
await traceModel.load(traceUrl, progress);
|
||||
} catch (error: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
|
||||
if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html'))
|
||||
throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.');
|
||||
else if (traceFileName)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
if (traceFileName)
|
||||
throw new Error(`Could not load trace from ${traceFileName}. Make sure to upload a valid Playwright trace.`);
|
||||
else
|
||||
throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`);
|
||||
throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`);
|
||||
}
|
||||
const snapshotServer = new SnapshotServer(traceModel.storage());
|
||||
loadedTraces.set(traceUrl, { traceModel, snapshotServer });
|
||||
|
|
|
|||
|
|
@ -279,9 +279,9 @@ export class TraceModel {
|
|||
params: metadata.params,
|
||||
wallTime: metadata.wallTime || Date.now(),
|
||||
log: metadata.log,
|
||||
beforeSnapshot: metadata.snapshots.find(s => s.snapshotName === 'before')?.snapshotName,
|
||||
inputSnapshot: metadata.snapshots.find(s => s.snapshotName === 'input')?.snapshotName,
|
||||
afterSnapshot: metadata.snapshots.find(s => s.snapshotName === 'after')?.snapshotName,
|
||||
beforeSnapshot: metadata.snapshots.find(s => s.title === 'before')?.snapshotName,
|
||||
inputSnapshot: metadata.snapshots.find(s => s.title === 'input')?.snapshotName,
|
||||
afterSnapshot: metadata.snapshots.find(s => s.title === 'after')?.snapshotName,
|
||||
error: metadata.error?.error,
|
||||
result: metadata.result,
|
||||
point: metadata.point,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
flex: none;
|
||||
align-items: center;
|
||||
margin: 0 4px;
|
||||
color: var(--gray);
|
||||
color: var(--vscode-editorCodeLens-foreground);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const renderAction = (
|
|||
revealConsole: () => void
|
||||
) => {
|
||||
const { errors, warnings } = modelUtil.stats(action);
|
||||
const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector) : undefined;
|
||||
const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector, false /* isFrameLocator */, true /* playSafe */) : undefined;
|
||||
|
||||
let time: string = '';
|
||||
if (action.endTime)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ function propertyToString(event: ActionTraceEvent, name: string, value: any, sdk
|
|||
if ((name === 'value' && isEval) || (name === 'received' && event.method === 'expect'))
|
||||
value = parseSerializedValue(value, new Array(10).fill({ handle: '<handle>' }));
|
||||
if (name === 'selector')
|
||||
return { text: asLocator(sdkLanguage || 'javascript', event.params.selector), type: 'locator', name: 'locator' };
|
||||
return { text: asLocator(sdkLanguage || 'javascript', event.params.selector, false /* isFrameLocator */, true /* playSafe */), type: 'locator', name: 'locator' };
|
||||
const type = typeof value;
|
||||
if (type !== 'object' || value === null)
|
||||
return { text: String(value), type, name };
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@
|
|||
import './filmStrip.css';
|
||||
import type { Boundaries, Size } from '../geometry';
|
||||
import * as React from 'react';
|
||||
import { useMeasure } from './helpers';
|
||||
import { upperBound } from '@web/uiUtils';
|
||||
import { useMeasure, upperBound } from '@web/uiUtils';
|
||||
import type { PageEntry } from '../entries';
|
||||
import type { MultiTraceModel } from './modelUtil';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -22,7 +22,8 @@ import type { ContextEntry, PageEntry } from '../entries';
|
|||
import type { SerializedError, StackFrame } from '@protocol/channels';
|
||||
|
||||
const contextSymbol = Symbol('context');
|
||||
const nextSymbol = Symbol('next');
|
||||
const nextInContextSymbol = Symbol('next');
|
||||
const prevInListSymbol = Symbol('prev');
|
||||
const eventsSymbol = Symbol('events');
|
||||
const resourcesSymbol = Symbol('resources');
|
||||
|
||||
|
|
@ -65,9 +66,8 @@ export class MultiTraceModel {
|
|||
this.events = ([] as EventTraceEvent[]).concat(...contexts.map(c => c.events));
|
||||
this.hasSource = contexts.some(c => c.hasSource);
|
||||
|
||||
this.actions.sort((a1, a2) => a1.startTime - a2.startTime);
|
||||
this.events.sort((a1, a2) => a1.time - a2.time);
|
||||
this.actions = dedupeActions(this.actions);
|
||||
this.actions = dedupeAndSortActions(this.actions);
|
||||
this.sources = collectSources(this.actions);
|
||||
}
|
||||
}
|
||||
|
|
@ -78,13 +78,13 @@ function indexModel(context: ContextEntry) {
|
|||
for (let i = 0; i < context.actions.length; ++i) {
|
||||
const action = context.actions[i] as any;
|
||||
action[contextSymbol] = context;
|
||||
action[nextSymbol] = context.actions[i + 1];
|
||||
action[nextInContextSymbol] = context.actions[i + 1];
|
||||
}
|
||||
for (const event of context.events)
|
||||
(event as any)[contextSymbol] = context;
|
||||
}
|
||||
|
||||
function dedupeActions(actions: ActionTraceEvent[]) {
|
||||
function dedupeAndSortActions(actions: ActionTraceEvent[]) {
|
||||
const callActions = actions.filter(a => a.callId.startsWith('call@'));
|
||||
const expectActions = actions.filter(a => a.callId.startsWith('expect@'));
|
||||
|
||||
|
|
@ -114,15 +114,22 @@ function dedupeActions(actions: ActionTraceEvent[]) {
|
|||
result.push(expectAction);
|
||||
}
|
||||
|
||||
return result.sort((a1, a2) => a1.startTime - a2.startTime);
|
||||
result.sort((a1, a2) => (a1.wallTime - a2.wallTime));
|
||||
for (let i = 1; i < result.length; ++i)
|
||||
(result[i] as any)[prevInListSymbol] = result[i - 1];
|
||||
return result;
|
||||
}
|
||||
|
||||
export function context(action: ActionTraceEvent): ContextEntry {
|
||||
return (action as any)[contextSymbol];
|
||||
}
|
||||
|
||||
function next(action: ActionTraceEvent): ActionTraceEvent {
|
||||
return (action as any)[nextSymbol];
|
||||
function nextInContext(action: ActionTraceEvent): ActionTraceEvent {
|
||||
return (action as any)[nextInContextSymbol];
|
||||
}
|
||||
|
||||
export function prevInList(action: ActionTraceEvent): ActionTraceEvent {
|
||||
return (action as any)[prevInListSymbol];
|
||||
}
|
||||
|
||||
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
|
||||
|
|
@ -149,7 +156,7 @@ export function eventsForAction(action: ActionTraceEvent): EventTraceEvent[] {
|
|||
if (result)
|
||||
return result;
|
||||
|
||||
const nextAction = next(action);
|
||||
const nextAction = nextInContext(action);
|
||||
result = context(action).events.filter(event => {
|
||||
return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime);
|
||||
});
|
||||
|
|
@ -162,7 +169,7 @@ export function resourcesForAction(action: ActionTraceEvent): ResourceSnapshot[]
|
|||
if (result)
|
||||
return result;
|
||||
|
||||
const nextAction = next(action);
|
||||
const nextAction = nextInContext(action);
|
||||
result = context(action).resources.filter(resource => {
|
||||
return typeof resource._monotonicTime === 'number' && resource._monotonicTime > action.startTime && (!nextAction || resource._monotonicTime < nextAction.startTime);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snapshot-tab .toolbar {
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.snapshot-controls {
|
||||
flex: none;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
|
|
@ -72,11 +76,25 @@
|
|||
box-shadow: 0 12px 28px 0 rgba(0,0,0,.2),0 2px 4px 0 rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
iframe#snapshot {
|
||||
.snapshot-switcher {
|
||||
width: 100%;
|
||||
height: calc(100% - var(--window-header-height));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
iframe[name=snapshot] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: white;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
iframe.snapshot-visible[name=snapshot] {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.no-snapshot {
|
||||
|
|
@ -153,4 +171,5 @@ body.dark-mode .window-header {
|
|||
|
||||
.snapshot-tab .cm-wrapper {
|
||||
line-height: 23px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,13 +16,12 @@
|
|||
|
||||
import './snapshotTab.css';
|
||||
import * as React from 'react';
|
||||
import { useMeasure } from './helpers';
|
||||
import type { ActionTraceEvent } from '@trace/trace';
|
||||
import { context } from './modelUtil';
|
||||
import { context, prevInList } from './modelUtil';
|
||||
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
||||
import { Toolbar } from '@web/components/toolbar';
|
||||
import { ToolbarButton } from '@web/components/toolbarButton';
|
||||
import { copy } from '@web/uiUtils';
|
||||
import { copy, useMeasure } from '@web/uiUtils';
|
||||
import { InjectedScript } from '@injected/injectedScript';
|
||||
import { Recorder } from '@injected/recorder';
|
||||
import { asLocator } from '@isomorphic/locatorGenerators';
|
||||
|
|
@ -36,75 +35,102 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
testIdAttributeName: string,
|
||||
}> = ({ action, sdkLanguage, testIdAttributeName }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
|
||||
const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action');
|
||||
const [isInspecting, setIsInspecting] = React.useState(false);
|
||||
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
|
||||
const [pickerVisible, setPickerVisible] = React.useState(false);
|
||||
|
||||
const { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => {
|
||||
const actionSnapshot = action?.inputSnapshot || action?.afterSnapshot;
|
||||
const snapshots = [
|
||||
actionSnapshot ? { title: 'action', snapshotName: actionSnapshot } : undefined,
|
||||
action?.beforeSnapshot ? { title: 'before', snapshotName: action?.beforeSnapshot } : undefined,
|
||||
action?.afterSnapshot ? { title: 'after', snapshotName: action.afterSnapshot } : undefined,
|
||||
].filter(Boolean) as { title: string, snapshotName: string }[];
|
||||
const { snapshots } = React.useMemo(() => {
|
||||
if (!action)
|
||||
return { snapshots: {} };
|
||||
|
||||
let snapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';
|
||||
let popoutUrl: string | undefined;
|
||||
let snapshotInfoUrl: string | undefined;
|
||||
let pointX: number | undefined;
|
||||
let pointY: number | undefined;
|
||||
if (action) {
|
||||
const snapshot = snapshots[snapshotIndex];
|
||||
if (snapshot && snapshot.snapshotName) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('trace', context(action).traceUrl);
|
||||
params.set('name', snapshot.snapshotName);
|
||||
snapshotUrl = new URL(`snapshot/${action.pageId}?${params.toString()}`, window.location.href).toString();
|
||||
snapshotInfoUrl = new URL(`snapshotInfo/${action.pageId}?${params.toString()}`, window.location.href).toString();
|
||||
if (snapshot.title === 'action') {
|
||||
pointX = action.point?.x;
|
||||
pointY = action.point?.y;
|
||||
}
|
||||
const popoutParams = new URLSearchParams();
|
||||
popoutParams.set('r', snapshotUrl);
|
||||
popoutParams.set('trace', context(action).traceUrl);
|
||||
popoutUrl = new URL(`popout.html?${popoutParams.toString()}`, window.location.href).toString();
|
||||
}
|
||||
// if the action has no beforeSnapshot, use the last available afterSnapshot.
|
||||
let beforeSnapshot = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined;
|
||||
let a = action;
|
||||
while (!beforeSnapshot && a) {
|
||||
a = prevInList(a);
|
||||
beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined;
|
||||
}
|
||||
const afterSnapshot = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot;
|
||||
const actionSnapshot = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot } : afterSnapshot;
|
||||
return { snapshots: { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot } };
|
||||
}, [action]);
|
||||
|
||||
const { snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => {
|
||||
const snapshot = snapshots[snapshotTab];
|
||||
if (!snapshot)
|
||||
return { snapshotUrl: kBlankSnapshotUrl };
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('trace', context(snapshot.action).traceUrl);
|
||||
params.set('name', snapshot.snapshotName);
|
||||
const snapshotUrl = new URL(`snapshot/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
|
||||
const snapshotInfoUrl = new URL(`snapshotInfo/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
|
||||
|
||||
const pointX = snapshotTab === 'action' ? snapshot.action.point?.x : undefined;
|
||||
const pointY = snapshotTab === 'action' ? snapshot.action.point?.y : undefined;
|
||||
const popoutParams = new URLSearchParams();
|
||||
popoutParams.set('r', snapshotUrl);
|
||||
popoutParams.set('trace', context(snapshot.action).traceUrl);
|
||||
const popoutUrl = new URL(`popout.html?${popoutParams.toString()}`, window.location.href).toString();
|
||||
return { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl };
|
||||
}, [action, snapshotIndex]);
|
||||
}, [snapshots, snapshotTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (snapshots.length >= 1 && snapshotIndex >= snapshots.length)
|
||||
setSnapshotIndex(snapshots.length - 1);
|
||||
}, [snapshotIndex, snapshots]);
|
||||
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const iframeRef0 = React.useRef<HTMLIFrameElement>(null);
|
||||
const iframeRef1 = React.useRef<HTMLIFrameElement>(null);
|
||||
const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' });
|
||||
const loadingRef = React.useRef({ iteration: 0, visibleIframe: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
const thisIteration = loadingRef.current.iteration + 1;
|
||||
const newVisibleIframe = 1 - loadingRef.current.visibleIframe;
|
||||
loadingRef.current.iteration = thisIteration;
|
||||
|
||||
const newSnapshotInfo = { url: '', viewport: kDefaultViewport };
|
||||
if (snapshotInfoUrl) {
|
||||
const response = await fetch(snapshotInfoUrl);
|
||||
const info = await response.json();
|
||||
if (!info.error)
|
||||
setSnapshotInfo(info);
|
||||
} else {
|
||||
setSnapshotInfo({ viewport: kDefaultViewport, url: '' });
|
||||
if (!info.error) {
|
||||
newSnapshotInfo.url = info.url;
|
||||
newSnapshotInfo.viewport = info.viewport;
|
||||
}
|
||||
}
|
||||
if (!iframeRef.current)
|
||||
|
||||
// Interrupted by another load - bail out.
|
||||
if (loadingRef.current.iteration !== thisIteration)
|
||||
return;
|
||||
try {
|
||||
const newUrl = snapshotUrl + (pointX === undefined ? '' : `&pointX=${pointX}&pointY=${pointY}`);
|
||||
// Try preventing history entry from being created.
|
||||
if (iframeRef.current.contentWindow)
|
||||
iframeRef.current.contentWindow.location.replace(newUrl);
|
||||
else
|
||||
iframeRef.current.src = newUrl;
|
||||
} catch (e) {
|
||||
|
||||
const iframe = [iframeRef0, iframeRef1][newVisibleIframe].current;
|
||||
if (iframe) {
|
||||
let loadedCallback = () => {};
|
||||
const loadedPromise = new Promise<void>(f => loadedCallback = f);
|
||||
try {
|
||||
iframe.addEventListener('load', loadedCallback);
|
||||
iframe.addEventListener('error', loadedCallback);
|
||||
|
||||
const newUrl = snapshotUrl + (pointX === undefined ? '' : `&pointX=${pointX}&pointY=${pointY}`);
|
||||
// Try preventing history entry from being created.
|
||||
if (iframe.contentWindow)
|
||||
iframe.contentWindow.location.replace(newUrl);
|
||||
else
|
||||
iframe.src = newUrl;
|
||||
|
||||
await loadedPromise;
|
||||
} catch {
|
||||
} finally {
|
||||
iframe.removeEventListener('load', loadedCallback);
|
||||
iframe.removeEventListener('error', loadedCallback);
|
||||
}
|
||||
}
|
||||
// Interrupted by another load - bail out.
|
||||
if (loadingRef.current.iteration !== thisIteration)
|
||||
return;
|
||||
|
||||
loadingRef.current.visibleIframe = newVisibleIframe;
|
||||
setSnapshotInfo(newSnapshotInfo);
|
||||
})();
|
||||
}, [iframeRef, snapshotUrl, snapshotInfoUrl, pointX, pointY]);
|
||||
}, [snapshotUrl, snapshotInfoUrl, pointX, pointY]);
|
||||
|
||||
const windowHeaderHeight = 40;
|
||||
const snapshotContainerSize = {
|
||||
|
|
@ -133,20 +159,26 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
testIdAttributeName={testIdAttributeName}
|
||||
highlightedLocator={highlightedLocator}
|
||||
setHighlightedLocator={setHighlightedLocator}
|
||||
iframe={iframeRef.current} />
|
||||
iframe={iframeRef0.current} />
|
||||
<InspectModeController
|
||||
isInspecting={isInspecting}
|
||||
sdkLanguage={sdkLanguage}
|
||||
testIdAttributeName={testIdAttributeName}
|
||||
highlightedLocator={highlightedLocator}
|
||||
setHighlightedLocator={setHighlightedLocator}
|
||||
iframe={iframeRef1.current} />
|
||||
<Toolbar>
|
||||
<ToolbarButton title='Pick locator' disabled={!popoutUrl} toggled={pickerVisible} onClick={() => {
|
||||
setPickerVisible(!pickerVisible);
|
||||
setHighlightedLocator('');
|
||||
setIsInspecting(!pickerVisible);
|
||||
}}>Pick locator</ToolbarButton>
|
||||
<div style={{ width: 5 }}></div>
|
||||
{snapshots.map((snapshot, index) => {
|
||||
{['action', 'before', 'after'].map(tab => {
|
||||
return <TabbedPaneTab
|
||||
id={snapshot.title}
|
||||
title={renderTitle(snapshot.title)}
|
||||
selected={snapshotIndex === index}
|
||||
onSelect={() => setSnapshotIndex(index)}
|
||||
id={tab}
|
||||
title={renderTitle(tab)}
|
||||
selected={snapshotTab === tab}
|
||||
onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')}
|
||||
></TabbedPaneTab>;
|
||||
})}
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
|
|
@ -154,7 +186,7 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
window.open(popoutUrl || '', '_blank');
|
||||
}}></ToolbarButton>
|
||||
</Toolbar>
|
||||
{pickerVisible && <Toolbar>
|
||||
{pickerVisible && <Toolbar noMinHeight={true}>
|
||||
<ToolbarButton icon='microscope' title='Pick locator' disabled={!popoutUrl} toggled={isInspecting} onClick={() => {
|
||||
setIsInspecting(!isInspecting);
|
||||
}}></ToolbarButton>
|
||||
|
|
@ -168,7 +200,7 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
}}></ToolbarButton>
|
||||
</Toolbar>}
|
||||
<div ref={ref} className='snapshot-wrapper'>
|
||||
{ snapshots.length ? <div className='snapshot-container' style={{
|
||||
<div className='snapshot-container' style={{
|
||||
width: snapshotContainerSize.width + 'px',
|
||||
height: snapshotContainerSize.height + 'px',
|
||||
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
|
||||
|
|
@ -188,9 +220,11 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe>
|
||||
</div> : <div className='no-snapshot'>Action does not have snapshots</div>
|
||||
}
|
||||
<div className='snapshot-switcher'>
|
||||
<iframe ref={iframeRef0} name='snapshot' className={loadingRef.current.visibleIframe === 0 ? 'snapshot-visible' : ''}></iframe>
|
||||
<iframe ref={iframeRef1} name='snapshot' className={loadingRef.current.visibleIframe === 1 ? 'snapshot-visible' : ''}></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
|
@ -215,15 +249,23 @@ export const InspectModeController: React.FunctionComponent<{
|
|||
}> = ({ iframe, isInspecting, sdkLanguage, testIdAttributeName, highlightedLocator, setHighlightedLocator }) => {
|
||||
React.useEffect(() => {
|
||||
const win = iframe?.contentWindow as any;
|
||||
if (!win || !isInspecting && !highlightedLocator && !win._recorder)
|
||||
let recorder: Recorder | undefined;
|
||||
try {
|
||||
if (!win)
|
||||
return;
|
||||
recorder = win._recorder;
|
||||
if (!recorder && !isInspecting && !highlightedLocator)
|
||||
return;
|
||||
} catch {
|
||||
// Potential cross-origin exception when accessing win._recorder.
|
||||
return;
|
||||
let recorder: Recorder | undefined = win._recorder;
|
||||
}
|
||||
if (!recorder) {
|
||||
const injectedScript = new InjectedScript(win, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
|
||||
recorder = new Recorder(injectedScript, {
|
||||
async setSelector(selector: string) {
|
||||
recorder!.setUIState({ mode: 'none', language: sdkLanguage, testIdAttributeName });
|
||||
setHighlightedLocator(asLocator('javascript', selector, false));
|
||||
setHighlightedLocator(asLocator('javascript', selector, false /* isFrameLocator */, true /* playSafe */));
|
||||
}
|
||||
});
|
||||
win._recorder = recorder;
|
||||
|
|
@ -240,3 +282,4 @@ export const InspectModeController: React.FunctionComponent<{
|
|||
};
|
||||
|
||||
const kDefaultViewport = { width: 1280, height: 720 };
|
||||
const kBlankSnapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';
|
||||
|
|
|
|||
|
|
@ -21,3 +21,13 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.source-tab-file-name {
|
||||
height: 24px;
|
||||
margin-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--vscode-breadcrumb-background);
|
||||
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,18 +17,21 @@
|
|||
import type { ActionTraceEvent } from '@trace/trace';
|
||||
import { SplitView } from '@web/components/splitView';
|
||||
import * as React from 'react';
|
||||
import { useAsyncMemo } from './helpers';
|
||||
import { useAsyncMemo } from '@web/uiUtils';
|
||||
import './sourceTab.css';
|
||||
import { StackTraceView } from './stackTrace';
|
||||
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
||||
import type { SourceHighlight } from '@web/components/codeMirrorWrapper';
|
||||
import type { SourceModel } from './modelUtil';
|
||||
import type { StackFrame } from '@protocol/channels';
|
||||
|
||||
export const SourceTab: React.FunctionComponent<{
|
||||
action: ActionTraceEvent | undefined,
|
||||
sources: Map<string, SourceModel>,
|
||||
hideStackFrames?: boolean,
|
||||
}> = ({ action, sources, hideStackFrames }) => {
|
||||
rootDir?: string,
|
||||
fallbackLocation?: StackFrame,
|
||||
}> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => {
|
||||
const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>();
|
||||
const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
|
||||
|
||||
|
|
@ -39,31 +42,42 @@ export const SourceTab: React.FunctionComponent<{
|
|||
}
|
||||
}, [action, lastAction, setLastAction, setSelectedFrame]);
|
||||
|
||||
const source = useAsyncMemo<SourceModel>(async () => {
|
||||
const file = action?.stack?.[selectedFrame].file;
|
||||
if (!file)
|
||||
return { errors: [], content: undefined };
|
||||
const source = sources.get(file)!;
|
||||
if (source.content === undefined) {
|
||||
const sha1 = await calculateSha1(file);
|
||||
const { source, highlight, targetLine, fileName } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[] }>(async () => {
|
||||
const location = action?.stack?.[selectedFrame] || fallbackLocation;
|
||||
if (!location?.file)
|
||||
return { source: { errors: [], content: undefined }, targetLine: 0, highlight: [] };
|
||||
|
||||
let source = sources.get(location.file);
|
||||
// Fallback location can fall outside the sources model.
|
||||
if (!source) {
|
||||
source = { errors: [], content: undefined };
|
||||
sources.set(location.file, source);
|
||||
}
|
||||
|
||||
const targetLine = location.line || 0;
|
||||
const fileName = rootDir && location.file.startsWith(rootDir) ? location.file.substring(rootDir.length + 1) : location.file;
|
||||
const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.location.line, message: e.error!.message }));
|
||||
highlight.push({ line: targetLine, type: 'running' });
|
||||
|
||||
if (source.content === undefined || fallbackLocation) {
|
||||
const sha1 = await calculateSha1(location.file);
|
||||
try {
|
||||
let response = await fetch(`sha1/src@${sha1}.txt`);
|
||||
if (response.status === 404)
|
||||
response = await fetch(`file?path=${file}`);
|
||||
response = await fetch(`file?path=${location.file}`);
|
||||
source.content = await response.text();
|
||||
} catch {
|
||||
source.content = `<Unable to read "${file}">`;
|
||||
source.content = `<Unable to read "${location.file}">`;
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}, [action, selectedFrame], { errors: [], content: 'Loading\u2026' });
|
||||
|
||||
const targetLine = action?.stack?.[selectedFrame]?.line || 0;
|
||||
const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.location.line, message: e.error!.message }));
|
||||
highlight.push({ line: targetLine, type: 'running' });
|
||||
return { source, highlight, targetLine, fileName };
|
||||
}, [action, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] });
|
||||
|
||||
return <SplitView sidebarSize={200} orientation='horizontal' sidebarHidden={hideStackFrames}>
|
||||
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
|
||||
<div className='vbox' data-testid='source-code'>
|
||||
{fileName && <div className='source-tab-file-name'>{fileName}</div>}
|
||||
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
|
||||
</div>
|
||||
<StackTraceView action={action} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />
|
||||
</SplitView>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@
|
|||
|
||||
.timeline-bar.frame_waitforeventinfo,
|
||||
.timeline-bar.page_waitforeventinfo {
|
||||
--action-color: var(--gray);
|
||||
--action-color: var(--vscode-editorCodeLens-foreground);
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,10 @@
|
|||
*/
|
||||
|
||||
import type { ActionTraceEvent, EventTraceEvent } from '@trace/trace';
|
||||
import { msToString } from '@web/uiUtils';
|
||||
import { msToString, useMeasure } from '@web/uiUtils';
|
||||
import * as React from 'react';
|
||||
import type { Boundaries } from '../geometry';
|
||||
import { FilmStrip } from './filmStrip';
|
||||
import { useMeasure } from './helpers';
|
||||
import type { MultiTraceModel } from './modelUtil';
|
||||
import './timeline.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -37,19 +37,24 @@
|
|||
.watch-mode-list-item-title {
|
||||
flex: auto;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.watch-mode-sidebar .toolbar {
|
||||
min-height: 24px;
|
||||
.watch-mode-list-item-time {
|
||||
flex: none;
|
||||
color: var(--vscode-editorCodeLens-foreground);
|
||||
margin: 0 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.watch-mode-sidebar .toolbar-button {
|
||||
margin: 0;
|
||||
.list-view-entry.selected .watch-mode-list-item-time,
|
||||
.list-view-entry.highlighted .watch-mode-list-item-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.watch-mode .section-title {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
|
|
@ -58,30 +63,30 @@
|
|||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.watch-mode-sidebar img {
|
||||
flex: none;
|
||||
margin: 0 4px;
|
||||
margin-left: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.status-line {
|
||||
flex: none;
|
||||
flex: auto;
|
||||
white-space: nowrap;
|
||||
line-height: 22px;
|
||||
padding: 0 10px;
|
||||
color: var(--vscode-statusBar-foreground);
|
||||
background-color: var(--vscode-statusBar-background);
|
||||
padding-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.status-line > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) {
|
||||
|
|
@ -103,7 +108,11 @@
|
|||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
padding: 0 10px 10px 10px;
|
||||
}
|
||||
|
||||
.filter-title,
|
||||
|
|
@ -121,14 +130,17 @@
|
|||
|
||||
.filter-summary {
|
||||
line-height: 24px;
|
||||
margin-top: 2px;
|
||||
margin-left: 20px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.filter-summary .filter-label {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.filter-entry {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.filter-entry label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { TreeView } from '@web/components/treeView';
|
|||
import type { TreeState } from '@web/components/treeView';
|
||||
import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver';
|
||||
import type { TeleTestCase } from '@testIsomorphic/teleReceiver';
|
||||
import type { FullConfig, Suite, TestCase, TestResult, Location } from '../../../playwright-test/types/testReporter';
|
||||
import type { FullConfig, Suite, TestCase, Location, TestError } from '../../../playwright-test/types/testReporter';
|
||||
import { SplitView } from '@web/components/splitView';
|
||||
import { MultiTraceModel } from './modelUtil';
|
||||
import './watchMode.css';
|
||||
|
|
@ -34,10 +34,10 @@ import { XtermWrapper } from '@web/components/xtermWrapper';
|
|||
import { Expandable } from '@web/components/expandable';
|
||||
import { toggleTheme } from '@web/theme';
|
||||
import { artifactsFolderName } from '@testIsomorphic/folders';
|
||||
import { settings } from '@web/uiUtils';
|
||||
import { msToString, settings, useSetting } from '@web/uiUtils';
|
||||
|
||||
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress) => void = () => {};
|
||||
let runWatchedTests = (fileName: string) => {};
|
||||
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {};
|
||||
let runWatchedTests = (fileNames: string[]) => {};
|
||||
let xtermSize = { cols: 80, rows: 24 };
|
||||
|
||||
const xtermDataSource: XtermDataSource = {
|
||||
|
|
@ -67,17 +67,22 @@ export const WatchModeView: React.FC<{}> = ({
|
|||
]));
|
||||
const [projectFilters, setProjectFilters] = React.useState<Map<string, boolean>>(new Map());
|
||||
const [testModel, setTestModel] = React.useState<TestModel>({ config: undefined, rootSuite: undefined });
|
||||
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0, skipped: 0 });
|
||||
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>(undefined);
|
||||
const [visibleTestIds, setVisibleTestIds] = React.useState<string[]>([]);
|
||||
const [progress, setProgress] = React.useState<Progress & { total: number } | undefined>();
|
||||
const [selectedItem, setSelectedItem] = React.useState<{ location?: Location, testCase?: TestCase }>({});
|
||||
const [visibleTestIds, setVisibleTestIds] = React.useState<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean }>();
|
||||
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>();
|
||||
const [watchAll, setWatchAll] = useSetting<boolean>('watch-all', false);
|
||||
const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() });
|
||||
const runTestPromiseChain = React.useRef(Promise.resolve());
|
||||
const runTestBacklog = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const reloadTests = () => {
|
||||
setIsLoading(true);
|
||||
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), { total: 0, passed: 0, failed: 0, skipped: 0 });
|
||||
setWatchedTreeIds({ value: new Set() });
|
||||
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined);
|
||||
refreshRootSuite(true).then(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
|
@ -88,7 +93,7 @@ export const WatchModeView: React.FC<{}> = ({
|
|||
reloadTests();
|
||||
}, []);
|
||||
|
||||
updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress) => {
|
||||
updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress | undefined) => {
|
||||
const selectedProjects = config.configFile ? settings.getObject<string[] | undefined>(config.configFile + ':projects', undefined) : undefined;
|
||||
for (const projectName of projectFilters.keys()) {
|
||||
if (!rootSuite.suites.find(s => s.title === projectName))
|
||||
|
|
@ -103,25 +108,38 @@ export const WatchModeView: React.FC<{}> = ({
|
|||
|
||||
setTestModel({ config, rootSuite });
|
||||
setProjectFilters(new Map(projectFilters));
|
||||
setProgress(newProgress);
|
||||
if (runningState && newProgress)
|
||||
setProgress({ ...newProgress, total: runningState.testIds.size });
|
||||
else if (!newProgress)
|
||||
setProgress(undefined);
|
||||
};
|
||||
|
||||
const runTests = (testIds: string[]) => {
|
||||
// Clear test results.
|
||||
{
|
||||
const testIdSet = new Set(testIds);
|
||||
for (const test of testModel.rootSuite?.allTests() || []) {
|
||||
if (testIdSet.has(test.id))
|
||||
(test as TeleTestCase)._createTestResult('pending');
|
||||
}
|
||||
setTestModel({ ...testModel });
|
||||
}
|
||||
const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set<string>) => {
|
||||
if (mode === 'bounce-if-busy' && runningState)
|
||||
return;
|
||||
|
||||
const time = ' [' + new Date().toLocaleTimeString() + ']';
|
||||
xtermDataSource.write('\x1B[2m—'.repeat(Math.max(0, xtermSize.cols - time.length)) + time + '\x1B[22m');
|
||||
setProgress({ total: testIds.length, passed: 0, failed: 0, skipped: 0 });
|
||||
setRunningState({ testIds: new Set(testIds) });
|
||||
sendMessage('run', { testIds }).then(() => {
|
||||
runTestBacklog.current = new Set([...runTestBacklog.current, ...testIds]);
|
||||
runTestPromiseChain.current = runTestPromiseChain.current.then(async () => {
|
||||
const testIds = runTestBacklog.current;
|
||||
runTestBacklog.current = new Set();
|
||||
if (!testIds.size)
|
||||
return;
|
||||
|
||||
// Clear test results.
|
||||
{
|
||||
for (const test of testModel.rootSuite?.allTests() || []) {
|
||||
if (testIds.has(test.id))
|
||||
(test as TeleTestCase)._createTestResult('pending');
|
||||
}
|
||||
setTestModel({ ...testModel });
|
||||
}
|
||||
|
||||
const time = ' [' + new Date().toLocaleTimeString() + ']';
|
||||
xtermDataSource.write('\x1B[2m—'.repeat(Math.max(0, xtermSize.cols - time.length)) + time + '\x1B[22m');
|
||||
setProgress({ total: testIds.size, passed: 0, failed: 0, skipped: 0 });
|
||||
setRunningState({ testIds });
|
||||
|
||||
await sendMessage('run', { testIds: [...testIds] });
|
||||
// Clear pending tests in case of interrupt.
|
||||
for (const test of testModel.rootSuite?.allTests() || []) {
|
||||
if (test.results[0]?.duration === -1)
|
||||
|
|
@ -130,33 +148,31 @@ export const WatchModeView: React.FC<{}> = ({
|
|||
setTestModel({ ...testModel });
|
||||
setRunningState(undefined);
|
||||
});
|
||||
};
|
||||
}, [runningState, testModel]);
|
||||
|
||||
const isRunningTest = !!runningState;
|
||||
const result = selectedTest?.results[0];
|
||||
const outputDir = selectedTest ? outputDirForTestCase(selectedTest) : undefined;
|
||||
|
||||
return <div className='vbox watch-mode'>
|
||||
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<div className='vbox'>
|
||||
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}>
|
||||
<Toolbar>
|
||||
<div className='section-title' style={{ flex: 'none' }}>Output</div>
|
||||
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>
|
||||
<div className='spacer'></div>
|
||||
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
|
||||
</Toolbar>
|
||||
<XtermWrapper source={xtermDataSource}></XtermWrapper>;
|
||||
<XtermWrapper source={xtermDataSource}></XtermWrapper>
|
||||
</div>
|
||||
<div className={'vbox' + (isShowingOutput ? ' hidden' : '')}>
|
||||
<TraceView outputDir={outputDir} testCase={selectedTest} result={result} />
|
||||
<TraceView item={selectedItem} rootDir={testModel.config?.rootDir} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='vbox watch-mode-sidebar'>
|
||||
<Toolbar>
|
||||
<Toolbar noShadow={true} noMinHeight={true}>
|
||||
<img src='icon-32x32.png' />
|
||||
<div className='section-title'>Playwright</div>
|
||||
<div className='spacer'></div>
|
||||
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()} />
|
||||
<ToolbarButton icon='color-mode' title='Toggle color mode' onClick={() => toggleTheme()} />
|
||||
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
|
||||
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
|
||||
</Toolbar>
|
||||
|
|
@ -168,12 +184,18 @@ export const WatchModeView: React.FC<{}> = ({
|
|||
projectFilters={projectFilters}
|
||||
setProjectFilters={setProjectFilters}
|
||||
testModel={testModel}
|
||||
runTests={() => runTests(visibleTestIds)} />
|
||||
<Toolbar>
|
||||
<div className='section-title'>Tests</div>
|
||||
<div className='spacer'></div>
|
||||
<ToolbarButton icon='play' title='Run all' onClick={() => runTests(visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
|
||||
runTests={() => runTests('bounce-if-busy', visibleTestIds)} />
|
||||
<Toolbar noMinHeight={true}>
|
||||
{!isRunningTest && !progress && <div className='section-title'>Tests</div>}
|
||||
{!isRunningTest && progress && <div data-testid='status-line' className='status-line'>
|
||||
<div>{progress.passed}/{progress.total} passed ({(progress.passed / progress.total) * 100 | 0}%)</div>
|
||||
</div>}
|
||||
{isRunningTest && progress && <div data-testid='status-line' className='status-line'>
|
||||
<div>Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)</div>
|
||||
</div>}
|
||||
<ToolbarButton icon='play' title='Run all' onClick={() => runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
|
||||
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}></ToolbarButton>
|
||||
<ToolbarButton icon='eye' title='Watch all' toggled={watchAll} onClick={() => setWatchAll(!watchAll)}></ToolbarButton>
|
||||
</Toolbar>
|
||||
<TestList
|
||||
statusFilters={statusFilters}
|
||||
|
|
@ -182,19 +204,14 @@ export const WatchModeView: React.FC<{}> = ({
|
|||
testModel={testModel}
|
||||
runningState={runningState}
|
||||
runTests={runTests}
|
||||
onTestSelected={setSelectedTest}
|
||||
setVisibleTestIds={setVisibleTestIds} />
|
||||
onItemSelected={setSelectedItem}
|
||||
setVisibleTestIds={setVisibleTestIds}
|
||||
watchAll={watchAll}
|
||||
watchedTreeIds={watchedTreeIds}
|
||||
setWatchedTreeIds={setWatchedTreeIds}
|
||||
isLoading={isLoading} />
|
||||
</div>
|
||||
</SplitView>
|
||||
<div className='status-line'>
|
||||
<div>Total: {progress.total}</div>
|
||||
{isRunningTest && <div><span className='codicon codicon-loading'></span>{`Running ${visibleTestIds.length}\u2026`}</div>}
|
||||
{isLoading && <div><span className='codicon codicon-loading'></span> {'Loading\u2026'}</div>}
|
||||
{!isRunningTest && <div>Showing: {visibleTestIds.length}</div>}
|
||||
<div>{progress.passed} passed</div>
|
||||
<div>{progress.failed} failed</div>
|
||||
<div>{progress.skipped} skipped</div>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
|
@ -228,40 +245,43 @@ const FiltersView: React.FC<{
|
|||
if (e.key === 'Enter')
|
||||
runTests();
|
||||
}} />}>
|
||||
{<div className='filter-title' title={statusLine} onClick={() => setExpanded(false)}><span className='filter-label'>Status:</span> {statusLine}</div>}
|
||||
{[...statusFilters.entries()].map(([status, value]) => {
|
||||
return <div className='filter-entry'>
|
||||
<label>
|
||||
<input type='checkbox' checked={value} onClick={() => {
|
||||
const copy = new Map(statusFilters);
|
||||
copy.set(status, !copy.get(status));
|
||||
setStatusFilters(copy);
|
||||
}}/>
|
||||
<div>{status}</div>
|
||||
</label>
|
||||
</div>;
|
||||
})}
|
||||
|
||||
{<div className='filter-title' title={projectsLine}><span className='filter-label'>Projects:</span> {projectsLine}</div>}
|
||||
{[...projectFilters.entries()].map(([projectName, value]) => {
|
||||
return <div className='filter-entry'>
|
||||
<label>
|
||||
<input type='checkbox' checked={value} onClick={() => {
|
||||
const copy = new Map(projectFilters);
|
||||
copy.set(projectName, !copy.get(projectName));
|
||||
setProjectFilters(copy);
|
||||
const configFile = testModel?.config?.configFile;
|
||||
if (configFile)
|
||||
settings.setObject(configFile + ':projects', [...copy.entries()].filter(([_, v]) => v).map(([k]) => k));
|
||||
}}/>
|
||||
<div>{projectName}</div>
|
||||
</label>
|
||||
</div>;
|
||||
})}
|
||||
</Expandable>
|
||||
{!expanded && <div className='filter-summary' title={'Status: ' + statusLine + '\nProjects: ' + projectsLine} onClick={() => setExpanded(true)}>
|
||||
<div className='filter-summary' title={'Status: ' + statusLine + '\nProjects: ' + projectsLine} onClick={() => setExpanded(!expanded)}>
|
||||
<span className='filter-label'>Status:</span> {statusLine}
|
||||
<span className='filter-label'>Projects:</span> {projectsLine}
|
||||
</div>
|
||||
{expanded && <div className='hbox' style={{ marginLeft: 14 }}>
|
||||
<div className='filter-list'>
|
||||
{[...statusFilters.entries()].map(([status, value]) => {
|
||||
return <div className='filter-entry'>
|
||||
<label>
|
||||
<input type='checkbox' checked={value} onClick={() => {
|
||||
const copy = new Map(statusFilters);
|
||||
copy.set(status, !copy.get(status));
|
||||
setStatusFilters(copy);
|
||||
}}/>
|
||||
<div>{status}</div>
|
||||
</label>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className='filter-list'>
|
||||
{[...projectFilters.entries()].map(([projectName, value]) => {
|
||||
return <div className='filter-entry'>
|
||||
<label>
|
||||
<input type='checkbox' checked={value} onClick={() => {
|
||||
const copy = new Map(projectFilters);
|
||||
copy.set(projectName, !copy.get(projectName));
|
||||
setProjectFilters(copy);
|
||||
const configFile = testModel?.config?.configFile;
|
||||
if (configFile)
|
||||
settings.setObject(configFile + ':projects', [...copy.entries()].filter(([_, v]) => v).map(([k]) => k));
|
||||
}}/>
|
||||
<div>{projectName}</div>
|
||||
</label>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
</div>;
|
||||
};
|
||||
|
|
@ -273,34 +293,44 @@ const TestList: React.FC<{
|
|||
projectFilters: Map<string, boolean>,
|
||||
filterText: string,
|
||||
testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined },
|
||||
runTests: (testIds: string[]) => void,
|
||||
runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void,
|
||||
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
|
||||
setVisibleTestIds: (testIds: string[]) => void,
|
||||
onTestSelected: (test: TestCase | undefined) => void,
|
||||
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, onTestSelected, setVisibleTestIds }) => {
|
||||
watchAll: boolean,
|
||||
watchedTreeIds: { value: Set<string> },
|
||||
setWatchedTreeIds: (ids: { value: Set<string> }) => void,
|
||||
isLoading?: boolean,
|
||||
setVisibleTestIds: (testIds: Set<string>) => void,
|
||||
onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void,
|
||||
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds }) => {
|
||||
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
|
||||
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
|
||||
const [watchedTreeIds] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const { rootItem, treeItemMap } = React.useMemo(() => {
|
||||
const rootItem = createTree(testModel.rootSuite, projectFilters);
|
||||
filterTree(rootItem, filterText, statusFilters);
|
||||
// Build the test tree.
|
||||
const { rootItem, treeItemMap, fileNames } = React.useMemo(() => {
|
||||
let rootItem = createTree(testModel.rootSuite, projectFilters);
|
||||
filterTree(rootItem, filterText, statusFilters, runningState?.testIds);
|
||||
sortAndPropagateStatus(rootItem);
|
||||
rootItem = shortenRoot(rootItem);
|
||||
|
||||
hideOnlyTests(rootItem);
|
||||
const treeItemMap = new Map<string, TreeItem>();
|
||||
const visibleTestIds = new Set<string>();
|
||||
const fileNames = new Set<string>();
|
||||
const visit = (treeItem: TreeItem) => {
|
||||
if (treeItem.kind === 'group' && treeItem.location.file)
|
||||
fileNames.add(treeItem.location.file);
|
||||
if (treeItem.kind === 'case')
|
||||
treeItem.tests.forEach(t => visibleTestIds.add(t.id));
|
||||
treeItem.children.forEach(visit);
|
||||
treeItemMap.set(treeItem.id, treeItem);
|
||||
};
|
||||
visit(rootItem);
|
||||
setVisibleTestIds([...visibleTestIds]);
|
||||
return { rootItem, treeItemMap };
|
||||
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds]);
|
||||
setVisibleTestIds(visibleTestIds);
|
||||
return { rootItem, treeItemMap, fileNames };
|
||||
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]);
|
||||
|
||||
// Look for a first failure within the run batch to select it.
|
||||
React.useEffect(() => {
|
||||
// Look for a first failure within the run batch to select it.
|
||||
if (!runningState || runningState.itemSelectedByUser)
|
||||
return;
|
||||
let selectedTreeItem: TreeItem | undefined;
|
||||
|
|
@ -321,39 +351,61 @@ const TestList: React.FC<{
|
|||
setSelectedTreeItemId(selectedTreeItem.id);
|
||||
}, [runningState, setSelectedTreeItemId, rootItem]);
|
||||
|
||||
// Compute selected item.
|
||||
const { selectedTreeItem } = React.useMemo(() => {
|
||||
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
|
||||
const location = selectedTreeItem?.location;
|
||||
let selectedTest: TestCase | undefined;
|
||||
if (selectedTreeItem?.kind === 'test')
|
||||
selectedTest = selectedTreeItem.test;
|
||||
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1)
|
||||
selectedTest = selectedTreeItem.tests[0];
|
||||
onTestSelected(selectedTest);
|
||||
onItemSelected({ testCase: selectedTest, location });
|
||||
return { selectedTreeItem };
|
||||
}, [onTestSelected, selectedTreeItemId, treeItemMap]);
|
||||
}, [onItemSelected, selectedTreeItemId, treeItemMap]);
|
||||
|
||||
const setWatchedTreeIds = (watchedTreeIds: Set<string>) => {
|
||||
const fileNames = new Set<string>();
|
||||
for (const itemId of watchedTreeIds) {
|
||||
const treeItem = treeItemMap.get(itemId)!;
|
||||
fileNames.add(fileNameForTreeItem(treeItem)!);
|
||||
// Update watch all.
|
||||
React.useEffect(() => {
|
||||
if (watchAll) {
|
||||
sendMessageNoReply('watch', { fileNames: [...fileNames] });
|
||||
} else {
|
||||
const fileNames = new Set<string>();
|
||||
for (const itemId of watchedTreeIds.value) {
|
||||
const treeItem = treeItemMap.get(itemId);
|
||||
const fileName = treeItem?.location.file;
|
||||
if (fileName)
|
||||
fileNames.add(fileName);
|
||||
}
|
||||
sendMessageNoReply('watch', { fileNames: [...fileNames] });
|
||||
}
|
||||
sendMessageNoReply('watch', { fileNames: [...fileNames] });
|
||||
};
|
||||
}, [rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]);
|
||||
|
||||
const runTreeItem = (treeItem: TreeItem) => {
|
||||
setSelectedTreeItemId(treeItem.id);
|
||||
runTests(collectTestIds(treeItem));
|
||||
runTests('bounce-if-busy', collectTestIds(treeItem));
|
||||
};
|
||||
|
||||
runWatchedTests = (fileName: string) => {
|
||||
runWatchedTests = (changedTestFiles: string[]) => {
|
||||
const testIds: string[] = [];
|
||||
for (const treeId of watchedTreeIds) {
|
||||
const treeItem = treeItemMap.get(treeId)!;
|
||||
if (fileNameForTreeItem(treeItem) === fileName)
|
||||
testIds.push(...collectTestIds(treeItem));
|
||||
const set = new Set(changedTestFiles);
|
||||
if (watchAll) {
|
||||
const visit = (treeItem: TreeItem) => {
|
||||
const fileName = treeItem.location.file;
|
||||
if (fileName && set.has(fileName))
|
||||
testIds.push(...collectTestIds(treeItem));
|
||||
if (treeItem.kind === 'group' && treeItem.subKind === 'folder')
|
||||
treeItem.children.forEach(visit);
|
||||
};
|
||||
visit(rootItem);
|
||||
} else {
|
||||
for (const treeId of watchedTreeIds.value) {
|
||||
const treeItem = treeItemMap.get(treeId);
|
||||
const fileName = treeItem?.location.file;
|
||||
if (fileName && set.has(fileName))
|
||||
testIds.push(...collectTestIds(treeItem));
|
||||
}
|
||||
}
|
||||
runTests(testIds);
|
||||
runTests('queue-if-busy', new Set(testIds));
|
||||
};
|
||||
|
||||
return <TestTreeView
|
||||
|
|
@ -364,18 +416,23 @@ const TestList: React.FC<{
|
|||
render={treeItem => {
|
||||
return <div className='hbox watch-mode-list-item'>
|
||||
<div className='watch-mode-list-item-title'>{treeItem.title}</div>
|
||||
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
|
||||
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
|
||||
<ToolbarButton icon='eye' title='Watch' onClick={() => {
|
||||
if (watchedTreeIds.has(treeItem.id))
|
||||
watchedTreeIds.delete(treeItem.id);
|
||||
else
|
||||
watchedTreeIds.add(treeItem.id);
|
||||
setWatchedTreeIds(watchedTreeIds);
|
||||
}} toggled={watchedTreeIds.has(treeItem.id)}></ToolbarButton>
|
||||
{!!treeItem.duration && treeItem.status !== 'skipped' && <div className='watch-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
|
||||
<Toolbar noMinHeight={true} noShadow={true}>
|
||||
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
|
||||
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
|
||||
{!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => {
|
||||
if (watchedTreeIds.value.has(treeItem.id))
|
||||
watchedTreeIds.value.delete(treeItem.id);
|
||||
else
|
||||
watchedTreeIds.value.add(treeItem.id);
|
||||
setWatchedTreeIds({ ...watchedTreeIds });
|
||||
}} toggled={watchedTreeIds.value.has(treeItem.id)}></ToolbarButton>}
|
||||
</Toolbar>
|
||||
</div>;
|
||||
}}
|
||||
icon={treeItem => {
|
||||
if (treeItem.status === 'scheduled')
|
||||
return 'codicon-clock';
|
||||
if (treeItem.status === 'running')
|
||||
return 'codicon-loading';
|
||||
if (treeItem.status === 'failed')
|
||||
|
|
@ -394,18 +451,23 @@ const TestList: React.FC<{
|
|||
setSelectedTreeItemId(treeItem.id);
|
||||
}}
|
||||
autoExpandDeep={!!filterText}
|
||||
noItemsMessage='No tests' />;
|
||||
noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />;
|
||||
};
|
||||
|
||||
const TraceView: React.FC<{
|
||||
outputDir: string | undefined,
|
||||
testCase: TestCase | undefined,
|
||||
result: TestResult | undefined,
|
||||
}> = ({ outputDir, testCase, result }) => {
|
||||
item: { location?: Location, testCase?: TestCase },
|
||||
rootDir?: string,
|
||||
}> = ({ item, rootDir }) => {
|
||||
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
|
||||
const [counter, setCounter] = React.useState(0);
|
||||
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { outputDir, result } = React.useMemo(() => {
|
||||
const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined;
|
||||
const result = item.testCase?.results[0];
|
||||
return { outputDir, result };
|
||||
}, [item]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (pollTimer.current)
|
||||
clearTimeout(pollTimer.current);
|
||||
|
|
@ -427,7 +489,7 @@ const TraceView: React.FC<{
|
|||
return;
|
||||
}
|
||||
|
||||
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${testCase?.id}.json`;
|
||||
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`;
|
||||
// Start polling running test.
|
||||
pollTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
|
|
@ -443,9 +505,16 @@ const TraceView: React.FC<{
|
|||
if (pollTimer.current)
|
||||
clearTimeout(pollTimer.current);
|
||||
};
|
||||
}, [result, outputDir, testCase, setModel, counter, setCounter]);
|
||||
}, [result, outputDir, item, setModel, counter, setCounter]);
|
||||
|
||||
return <Workbench key='workbench' model={model} hideTimelineBars={true} hideStackFrames={true} showSourcesFirst={true} />;
|
||||
return <Workbench
|
||||
key='workbench'
|
||||
model={model}
|
||||
hideTimelineBars={true}
|
||||
hideStackFrames={true}
|
||||
showSourcesFirst={true}
|
||||
rootDir={rootDir}
|
||||
defaultSourceLocation={item.location} />;
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
|
@ -478,7 +547,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
|
|||
|
||||
let rootSuite: Suite;
|
||||
const progress: Progress = {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
|
|
@ -489,7 +557,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
|
|||
if (!rootSuite)
|
||||
rootSuite = suite;
|
||||
config = c;
|
||||
progress.total = suite.allTests().length;
|
||||
progress.passed = 0;
|
||||
progress.failed = 0;
|
||||
progress.skipped = 0;
|
||||
|
|
@ -513,6 +580,10 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
|
|||
++progress.passed;
|
||||
throttleUpdateRootSuite(config, rootSuite, progress);
|
||||
},
|
||||
|
||||
onError: (error: TestError) => {
|
||||
xtermDataSource.write((error.stack || error.value || '') + '\n');
|
||||
},
|
||||
});
|
||||
return sendMessage('list', {});
|
||||
};
|
||||
|
|
@ -523,8 +594,8 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (message.method === 'fileChanged') {
|
||||
runWatchedTests(message.params.fileName);
|
||||
if (message.method === 'testFilesChanged') {
|
||||
runWatchedTests(message.params.testFileNames);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -546,6 +617,10 @@ const sendMessage = async (method: string, params: any) => {
|
|||
};
|
||||
|
||||
const sendMessageNoReply = (method: string, params?: any) => {
|
||||
if ((window as any)._overrideProtocolForTest) {
|
||||
(window as any)._overrideProtocolForTest({ method, params }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
sendMessage(method, params).catch((e: Error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
|
|
@ -560,25 +635,22 @@ const outputDirForTestCase = (testCase: TestCase): string | undefined => {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
const fileNameForTreeItem = (treeItem?: TreeItem): string | undefined => {
|
||||
return treeItem?.location.file;
|
||||
};
|
||||
|
||||
const locationToOpen = (treeItem?: TreeItem) => {
|
||||
if (!treeItem)
|
||||
return;
|
||||
return treeItem.location.file + ':' + treeItem.location.line;
|
||||
};
|
||||
|
||||
const collectTestIds = (treeItem?: TreeItem): string[] => {
|
||||
const collectTestIds = (treeItem?: TreeItem): Set<string> => {
|
||||
const testIds = new Set<string>();
|
||||
if (!treeItem)
|
||||
return [];
|
||||
const testIds: string[] = [];
|
||||
return testIds;
|
||||
|
||||
const visit = (treeItem: TreeItem) => {
|
||||
if (treeItem.kind === 'case')
|
||||
testIds.push(...treeItem.tests.map(t => t.id));
|
||||
treeItem.tests.map(t => t.id).forEach(id => testIds.add(id));
|
||||
else if (treeItem.kind === 'test')
|
||||
testIds.push(treeItem.id);
|
||||
testIds.add(treeItem.id);
|
||||
else
|
||||
treeItem.children?.forEach(visit);
|
||||
};
|
||||
|
|
@ -587,7 +659,6 @@ const collectTestIds = (treeItem?: TreeItem): string[] => {
|
|||
};
|
||||
|
||||
type Progress = {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
|
|
@ -598,13 +669,15 @@ type TreeItemBase = {
|
|||
id: string;
|
||||
title: string;
|
||||
location: Location,
|
||||
duration: number;
|
||||
parent: TreeItem | undefined;
|
||||
children: TreeItem[];
|
||||
status: 'none' | 'running' | 'passed' | 'failed' | 'skipped';
|
||||
status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped';
|
||||
};
|
||||
|
||||
type GroupItem = TreeItemBase & {
|
||||
kind: 'group',
|
||||
kind: 'group';
|
||||
subKind: 'folder' | 'file' | 'describe';
|
||||
children: (TestCaseItem | GroupItem)[];
|
||||
};
|
||||
|
||||
|
|
@ -622,13 +695,39 @@ type TestItem = TreeItemBase & {
|
|||
|
||||
type TreeItem = GroupItem | TestCaseItem | TestItem;
|
||||
|
||||
function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, fileItems: Map<string, GroupItem>): GroupItem {
|
||||
if (filePath.length === 0)
|
||||
return rootItem;
|
||||
const fileName = filePath.join(pathSeparator);
|
||||
const existingFileItem = fileItems.get(fileName);
|
||||
if (existingFileItem)
|
||||
return existingFileItem;
|
||||
const parentFileItem = getFileItem(rootItem, filePath.slice(0, filePath.length - 1), false, fileItems);
|
||||
const fileItem: GroupItem = {
|
||||
kind: 'group',
|
||||
subKind: isFile ? 'file' : 'folder',
|
||||
id: fileName,
|
||||
title: filePath[filePath.length - 1],
|
||||
location: { file: fileName, line: 0, column: 0 },
|
||||
duration: 0,
|
||||
parent: parentFileItem,
|
||||
children: [],
|
||||
status: 'none',
|
||||
};
|
||||
parentFileItem.children.push(fileItem);
|
||||
fileItems.set(fileName, fileItem);
|
||||
return fileItem;
|
||||
}
|
||||
|
||||
function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, boolean>): GroupItem {
|
||||
const filterProjects = [...projectFilters.values()].some(Boolean);
|
||||
const rootItem: GroupItem = {
|
||||
kind: 'group',
|
||||
subKind: 'folder',
|
||||
id: 'root',
|
||||
title: '',
|
||||
location: { file: '', line: 0, column: 0 },
|
||||
duration: 0,
|
||||
parent: undefined,
|
||||
children: [],
|
||||
status: 'none',
|
||||
|
|
@ -636,14 +735,16 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
|
|||
|
||||
const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => {
|
||||
for (const suite of parentSuite.suites) {
|
||||
const title = suite.title;
|
||||
const title = suite.title || '<anonymous>';
|
||||
let group = parentGroup.children.find(item => item.title === title) as GroupItem | undefined;
|
||||
if (!group) {
|
||||
group = {
|
||||
kind: 'group',
|
||||
subKind: 'describe',
|
||||
id: parentGroup.id + '\x1e' + title,
|
||||
title,
|
||||
location: suite.location!,
|
||||
duration: 0,
|
||||
parent: parentGroup,
|
||||
children: [],
|
||||
status: 'none',
|
||||
|
|
@ -665,21 +766,25 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
|
|||
children: [],
|
||||
tests: [],
|
||||
location: test.location,
|
||||
duration: 0,
|
||||
status: 'none',
|
||||
};
|
||||
parentGroup.children.push(testCaseItem);
|
||||
}
|
||||
|
||||
let status: 'none' | 'running' | 'passed' | 'failed' | 'skipped' = 'none';
|
||||
if (test.results.some(r => r.duration === -1))
|
||||
const result = (test as TeleTestCase).results[0];
|
||||
let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none';
|
||||
if (result?.statusEx === 'scheduled')
|
||||
status = 'scheduled';
|
||||
else if (result?.statusEx === 'running')
|
||||
status = 'running';
|
||||
else if (test.results.length && test.results[0].status === 'skipped')
|
||||
else if (result?.status === 'skipped')
|
||||
status = 'skipped';
|
||||
else if (test.results.length && test.results[0].status === 'interrupted')
|
||||
else if (result?.status === 'interrupted')
|
||||
status = 'none';
|
||||
else if (test.results.length && test.outcome() !== 'expected')
|
||||
else if (result && test.outcome() !== 'expected')
|
||||
status = 'failed';
|
||||
else if (test.results.length && test.outcome() === 'expected')
|
||||
else if (result && test.outcome() === 'expected')
|
||||
status = 'passed';
|
||||
|
||||
testCaseItem.tests.push(test);
|
||||
|
|
@ -692,58 +797,36 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
|
|||
parent: testCaseItem,
|
||||
children: [],
|
||||
status,
|
||||
project: projectName
|
||||
duration: test.results.length ? Math.max(0, test.results[0].duration) : 0,
|
||||
project: projectName,
|
||||
});
|
||||
testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const fileMap = new Map<string, GroupItem>();
|
||||
for (const projectSuite of rootSuite?.suites || []) {
|
||||
if (filterProjects && !projectFilters.get(projectSuite.title))
|
||||
continue;
|
||||
visitSuite(projectSuite.title, projectSuite, rootItem);
|
||||
}
|
||||
|
||||
const sortAndPropagateStatus = (treeItem: TreeItem) => {
|
||||
for (const child of treeItem.children)
|
||||
sortAndPropagateStatus(child);
|
||||
|
||||
if (treeItem.kind === 'group' && treeItem.parent)
|
||||
treeItem.children.sort((a, b) => a.location.line - b.location.line);
|
||||
|
||||
let allPassed = treeItem.children.length > 0;
|
||||
let allSkipped = treeItem.children.length > 0;
|
||||
let hasFailed = false;
|
||||
let hasRunning = false;
|
||||
|
||||
for (const child of treeItem.children) {
|
||||
allSkipped = allSkipped && child.status === 'skipped';
|
||||
allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped');
|
||||
hasFailed = hasFailed || child.status === 'failed';
|
||||
hasRunning = hasRunning || child.status === 'running';
|
||||
for (const fileSuite of projectSuite.suites) {
|
||||
const fileItem = getFileItem(rootItem, fileSuite.location!.file.split(pathSeparator), true, fileMap);
|
||||
visitSuite(projectSuite.title, fileSuite, fileItem);
|
||||
}
|
||||
|
||||
if (hasRunning)
|
||||
treeItem.status = 'running';
|
||||
else if (hasFailed)
|
||||
treeItem.status = 'failed';
|
||||
else if (allSkipped)
|
||||
treeItem.status = 'skipped';
|
||||
else if (allPassed)
|
||||
treeItem.status = 'passed';
|
||||
};
|
||||
sortAndPropagateStatus(rootItem);
|
||||
}
|
||||
return rootItem;
|
||||
}
|
||||
|
||||
function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<string, boolean>) {
|
||||
function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<string, boolean>, runningTestIds: Set<string> | undefined) {
|
||||
const tokens = filterText.trim().toLowerCase().split(' ');
|
||||
const filtersStatuses = [...statusFilters.values()].some(Boolean);
|
||||
|
||||
const filter = (testCase: TestCaseItem) => {
|
||||
const title = testCase.tests[0].titlePath().join(' ').toLowerCase();
|
||||
if (!tokens.every(token => title.includes(token)))
|
||||
if (!tokens.every(token => title.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id)))
|
||||
return false;
|
||||
testCase.children = (testCase.children as TestItem[]).filter(test => !filtersStatuses || statusFilters.get(test.status));
|
||||
testCase.children = (testCase.children as TestItem[]).filter(test => {
|
||||
return !filtersStatuses || runningTestIds?.has(test.id) || statusFilters.get(test.status);
|
||||
});
|
||||
testCase.tests = (testCase.children as TestItem[]).map(c => c.test);
|
||||
return !!testCase.children.length;
|
||||
};
|
||||
|
|
@ -765,6 +848,51 @@ function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<
|
|||
visit(rootItem);
|
||||
}
|
||||
|
||||
function sortAndPropagateStatus(treeItem: TreeItem) {
|
||||
for (const child of treeItem.children)
|
||||
sortAndPropagateStatus(child);
|
||||
|
||||
if (treeItem.kind === 'group') {
|
||||
treeItem.children.sort((a, b) => {
|
||||
const fc = a.location.file.localeCompare(b.location.file);
|
||||
return fc || a.location.line - b.location.line;
|
||||
});
|
||||
}
|
||||
|
||||
let allPassed = treeItem.children.length > 0;
|
||||
let allSkipped = treeItem.children.length > 0;
|
||||
let hasFailed = false;
|
||||
let hasRunning = false;
|
||||
let hasScheduled = false;
|
||||
|
||||
for (const child of treeItem.children) {
|
||||
allSkipped = allSkipped && child.status === 'skipped';
|
||||
allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped');
|
||||
hasFailed = hasFailed || child.status === 'failed';
|
||||
hasRunning = hasRunning || child.status === 'running';
|
||||
hasScheduled = hasScheduled || child.status === 'scheduled';
|
||||
}
|
||||
|
||||
if (hasRunning)
|
||||
treeItem.status = 'running';
|
||||
else if (hasScheduled)
|
||||
treeItem.status = 'scheduled';
|
||||
else if (hasFailed)
|
||||
treeItem.status = 'failed';
|
||||
else if (allSkipped)
|
||||
treeItem.status = 'skipped';
|
||||
else if (allPassed)
|
||||
treeItem.status = 'passed';
|
||||
}
|
||||
|
||||
function shortenRoot(rootItem: GroupItem): GroupItem {
|
||||
let shortRoot = rootItem;
|
||||
while (shortRoot.children.length === 1 && shortRoot.children[0].kind === 'group' && shortRoot.children[0].subKind === 'folder')
|
||||
shortRoot = shortRoot.children[0];
|
||||
shortRoot.location = rootItem.location;
|
||||
return shortRoot;
|
||||
}
|
||||
|
||||
function hideOnlyTests(rootItem: GroupItem) {
|
||||
const visit = (treeItem: TreeItem) => {
|
||||
if (treeItem.kind === 'case' && treeItem.children.length === 1)
|
||||
|
|
@ -782,3 +910,5 @@ async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
|
|||
const contextEntries = await response.json() as ContextEntry[];
|
||||
return new MultiTraceModel(contextEntries);
|
||||
}
|
||||
|
||||
const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/';
|
||||
|
|
|
|||
|
|
@ -30,19 +30,24 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
|
|||
import { Timeline } from './timeline';
|
||||
import './workbench.css';
|
||||
import { MetadataView } from './metadataView';
|
||||
import type { Location } from '../../../playwright-test/types/testReporter';
|
||||
|
||||
export const Workbench: React.FunctionComponent<{
|
||||
model?: MultiTraceModel,
|
||||
hideTimelineBars?: boolean,
|
||||
hideStackFrames?: boolean,
|
||||
showSourcesFirst?: boolean,
|
||||
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst }) => {
|
||||
rootDir?: string,
|
||||
defaultSourceLocation?: Location,
|
||||
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, defaultSourceLocation }) => {
|
||||
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>(undefined);
|
||||
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
|
||||
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
|
||||
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>(showSourcesFirst ? 'source' : 'call');
|
||||
const activeAction = model ? highlightedAction || selectedAction : undefined;
|
||||
|
||||
const sources = React.useMemo(() => model?.sources || new Map(), [model]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedAction && model?.actions.includes(selectedAction))
|
||||
return;
|
||||
|
|
@ -66,7 +71,12 @@ export const Workbench: React.FunctionComponent<{
|
|||
const sourceTab: TabbedPaneTabModel = {
|
||||
id: 'source',
|
||||
title: 'Source',
|
||||
render: () => <SourceTab action={activeAction} sources={model?.sources || new Map()} hideStackFrames={hideStackFrames}/>
|
||||
render: () => <SourceTab
|
||||
action={activeAction}
|
||||
sources={sources}
|
||||
hideStackFrames={hideStackFrames}
|
||||
rootDir={rootDir}
|
||||
fallbackLocation={defaultSourceLocation} />
|
||||
};
|
||||
const consoleTab: TabbedPaneTabModel = {
|
||||
id: 'console',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@ body.dark-mode ::-webkit-scrollbar-track:hover {
|
|||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
[*]
|
||||
../theme.ts
|
||||
../third_party/vscode/codicon.css
|
||||
../uiUtils.ts
|
||||
|
||||
[expandable.spec.tsx]
|
||||
***
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ body.dark-mode .CodeMirror span.cm-type {
|
|||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
border-left: 1px solid #bebebe;
|
||||
border-left: 1px solid var(--vscode-editor-foreground) !important;
|
||||
}
|
||||
|
||||
.CodeMirror div.CodeMirror-selected {
|
||||
|
|
@ -122,8 +122,11 @@ body.dark-mode .CodeMirror span.cm-type {
|
|||
border-right: none;
|
||||
}
|
||||
|
||||
.CodeMirror .CodeMirror-gutter-elt {
|
||||
background-color: var(--vscode-editorGutter-background);
|
||||
}
|
||||
|
||||
.CodeMirror .CodeMirror-gutterwrapper {
|
||||
background: var(--vscode-editor-background);
|
||||
border-right: 1px solid var(--vscode-editorGroup-border);
|
||||
color: var(--vscode-editorLineNumber-foreground);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import './codeMirrorWrapper.css';
|
|||
import * as React from 'react';
|
||||
import type { CodeMirror } from './codeMirrorModule';
|
||||
import { ansi2htmlMarkup } from './errorMessage';
|
||||
import { useMeasure } from '../uiUtils';
|
||||
|
||||
export type SourceHighlight = {
|
||||
line: number;
|
||||
|
|
@ -51,7 +52,7 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
|
|||
wrapLines,
|
||||
onChange,
|
||||
}) => {
|
||||
const codemirrorElement = React.useRef<HTMLDivElement>(null);
|
||||
const [measure, codemirrorElement] = useMeasure<HTMLDivElement>();
|
||||
const [modulePromise] = React.useState<Promise<CodeMirror>>(import('./codeMirrorModule').then(m => m.default));
|
||||
const codemirrorRef = React.useRef<{ cm: CodeMirror.Editor, highlight?: SourceHighlight[], widgets?: CodeMirror.LineWidget[] } | null>(null);
|
||||
const [codemirror, setCodemirror] = React.useState<CodeMirror.Editor>();
|
||||
|
|
@ -98,6 +99,11 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
|
|||
})();
|
||||
}, [modulePromise, codemirror, codemirrorElement, language, lineNumbers, wrapLines, readOnly]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (codemirrorRef.current)
|
||||
codemirrorRef.current.cm.setSize(measure.width, measure.height);
|
||||
}, [measure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!codemirror)
|
||||
return;
|
||||
|
|
@ -149,9 +155,9 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
|
|||
codemirrorRef.current!.highlight = highlight;
|
||||
codemirrorRef.current!.widgets = widgets;
|
||||
}
|
||||
|
||||
if (revealLine && codemirrorRef.current!.cm.lineCount() >= revealLine)
|
||||
codemirror.scrollIntoView({ line: revealLine - 1, ch: 0 }, 50);
|
||||
// Line-less locations have line = 0, but they mean to reveal the file.
|
||||
if (typeof revealLine === 'number' && codemirrorRef.current!.cm.lineCount() >= revealLine)
|
||||
codemirror.scrollIntoView({ line: Math.max(0, revealLine - 1), ch: 0 }, 50);
|
||||
}, [codemirror, text, highlight, revealLine, focusOnChange, onChange]);
|
||||
|
||||
return <div className='cm-wrapper' ref={codemirrorElement}></div>;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabbed-pane .toolbar {
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.tabbed-pane .tab-content {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
|
|
@ -28,7 +32,6 @@
|
|||
|
||||
.tabbed-pane-tab {
|
||||
padding: 2px 10px 0 10px;
|
||||
margin-right: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: none;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const TabbedPane: React.FunctionComponent<{
|
|||
if (tab.component)
|
||||
return <div key={tab.id} className='tab-content' style={{ display: selectedTab === tab.id ? 'inherit' : 'none' }}>{tab.component}</div>;
|
||||
if (selectedTab === tab.id)
|
||||
return <div key={tab.id} className='tab-content'>{tab.component || tab.render!()}</div>;
|
||||
return <div key={tab.id} className='tab-content'>{tab.render!()}</div>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,19 +15,34 @@
|
|||
*/
|
||||
|
||||
.toolbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
box-shadow: var(--box-shadow);
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
color: var(--vscode-sideBarTitle-foreground);
|
||||
min-height: 35px;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
z-index: 2;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.toolbar-linewrap {
|
||||
.toolbar:after {
|
||||
content: '';
|
||||
display: block;
|
||||
flex: auto;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toolbar.no-shadow:after {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toolbar.no-min-height {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
|
|
|
|||
|
|
@ -17,11 +17,15 @@
|
|||
import './toolbar.css';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface ToolbarProps {
|
||||
}
|
||||
type ToolbarProps = {
|
||||
noShadow?: boolean;
|
||||
noMinHeight?: boolean;
|
||||
};
|
||||
|
||||
export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
|
||||
children
|
||||
noShadow,
|
||||
children,
|
||||
noMinHeight
|
||||
}) => {
|
||||
return <div className='toolbar'>{children}</div>;
|
||||
return <div className={'toolbar' + (noShadow ? ' no-shadow' : '') + (noMinHeight ? ' no-min-height' : '')}>{children}</div>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
color: var(--vscode-sideBarTitle-foreground);
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
margin: 0 4px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -43,3 +42,7 @@
|
|||
.toolbar-button.toggled {
|
||||
color: var(--vscode-notificationLink-foreground);
|
||||
}
|
||||
|
||||
.toolbar-button.toggled .codicon {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
|
|||
className={className}
|
||||
onMouseDown={preventDefault}
|
||||
onClick={onClick}
|
||||
onDoubleClick={preventDefault}
|
||||
title={title}
|
||||
disabled={!!disabled}>
|
||||
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
|
||||
|
|
@ -48,4 +49,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
|
|||
</button>;
|
||||
};
|
||||
|
||||
const preventDefault = (e: any) => e.preventDefault();
|
||||
const preventDefault = (e: any) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@
|
|||
import * as React from 'react';
|
||||
import './xtermWrapper.css';
|
||||
import type { ITheme, Terminal } from 'xterm';
|
||||
import type { FitAddon } from 'xterm-addon-fit';
|
||||
import type { XtermModule } from './xtermModule';
|
||||
import { isDarkTheme } from '@web/theme';
|
||||
import { currentTheme, addThemeListener, removeThemeListener } from '@web/theme';
|
||||
import { useMeasure } from '@web/uiUtils';
|
||||
|
||||
export type XtermDataSource = {
|
||||
pending: (string | Uint8Array)[];
|
||||
|
|
@ -28,12 +30,22 @@ export type XtermDataSource = {
|
|||
};
|
||||
|
||||
export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
|
||||
source
|
||||
source,
|
||||
}) => {
|
||||
const xtermElement = React.useRef<HTMLDivElement>(null);
|
||||
const [measure, xtermElement] = useMeasure<HTMLDivElement>();
|
||||
const [theme, setTheme] = React.useState(currentTheme());
|
||||
const [modulePromise] = React.useState<Promise<XtermModule>>(import('./xtermModule').then(m => m.default));
|
||||
const terminal = React.useRef<Terminal | null>(null);
|
||||
const terminal = React.useRef<{ terminal: Terminal, fitAddon: FitAddon } | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
addThemeListener(setTheme);
|
||||
return () => removeThemeListener(setTheme);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const oldSourceWrite = source.write;
|
||||
const oldSourceClear = source.clear;
|
||||
|
||||
(async () => {
|
||||
// Always load the module first.
|
||||
const { Terminal, FitAddon } = await modulePromise;
|
||||
|
|
@ -41,15 +53,19 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
|
|||
if (!element)
|
||||
return;
|
||||
|
||||
if (terminal.current)
|
||||
const terminalTheme = theme === 'dark-mode' ? darkTheme : lightTheme;
|
||||
if (terminal.current && terminal.current.terminal.options.theme === terminalTheme)
|
||||
return;
|
||||
|
||||
if (terminal.current)
|
||||
element.textContent = '';
|
||||
|
||||
const newTerminal = new Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 13,
|
||||
scrollback: 10000,
|
||||
fontFamily: 'var(--vscode-editor-font-family)',
|
||||
theme: isDarkTheme() ? darkTheme : lightTheme
|
||||
theme: terminalTheme,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
|
|
@ -66,16 +82,26 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
|
|||
};
|
||||
newTerminal.open(element);
|
||||
fitAddon.fit();
|
||||
terminal.current = newTerminal;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
source.resize(newTerminal.cols, newTerminal.rows);
|
||||
fitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(element);
|
||||
terminal.current = { terminal: newTerminal, fitAddon };
|
||||
})();
|
||||
}, [modulePromise, terminal, xtermElement, source]);
|
||||
return <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}>
|
||||
</div>;
|
||||
return () => {
|
||||
source.clear = oldSourceClear;
|
||||
source.write = oldSourceWrite;
|
||||
};
|
||||
}, [modulePromise, terminal, xtermElement, source, theme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Fit reads data from the terminal itself, which updates lazily, probably on some timer
|
||||
// or mutation observer. Work around it.
|
||||
setTimeout(() => {
|
||||
if (!terminal.current)
|
||||
return;
|
||||
terminal.current.fitAddon.fit();
|
||||
source.resize(terminal.current.terminal.cols, terminal.current.terminal.rows);
|
||||
}, 250);
|
||||
}, [measure, source]);
|
||||
|
||||
return <div data-testid='output' className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}></div>;
|
||||
};
|
||||
|
||||
const lightTheme: ITheme = {
|
||||
|
|
|
|||
|
|
@ -34,9 +34,12 @@ export function applyTheme() {
|
|||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
|
||||
type Theme = 'dark-mode' | 'light-mode';
|
||||
|
||||
const listeners = new Set<(theme: Theme) => void>();
|
||||
export function toggleTheme() {
|
||||
const oldTheme = settings.getString('theme', 'light-mode');
|
||||
let newTheme: string;
|
||||
let newTheme: Theme;
|
||||
if (oldTheme === 'dark-mode')
|
||||
newTheme = 'light-mode';
|
||||
else
|
||||
|
|
@ -46,8 +49,18 @@ export function toggleTheme() {
|
|||
document.body.classList.remove(oldTheme);
|
||||
document.body.classList.add(newTheme);
|
||||
settings.setString('theme', newTheme);
|
||||
for (const listener of listeners)
|
||||
listener(newTheme);
|
||||
}
|
||||
|
||||
export function isDarkTheme() {
|
||||
return document.body.classList.contains('dark-mode');
|
||||
export function addThemeListener(listener: (theme: 'light-mode' | 'dark-mode') => void) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
export function removeThemeListener(listener: (theme: Theme) => void) {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function currentTheme(): Theme {
|
||||
return document.body.classList.contains('dark-mode') ? 'dark-mode' : 'light-mode';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,44 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
// Recalculates the value when dependencies change.
|
||||
export function useAsyncMemo<T>(fn: () => Promise<T>, deps: React.DependencyList, initialValue: T, resetValue?: T) {
|
||||
const [value, setValue] = React.useState<T>(initialValue);
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
if (resetValue !== undefined)
|
||||
setValue(resetValue);
|
||||
fn().then(value => {
|
||||
if (!canceled)
|
||||
setValue(value);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Tracks the element size and returns it's contentRect (always has x=0, y=0).
|
||||
export function useMeasure<T extends Element>() {
|
||||
const ref = React.useRef<T | null>(null);
|
||||
const [measure, setMeasure] = React.useState(new DOMRect(0, 0, 10, 10));
|
||||
React.useLayoutEffect(() => {
|
||||
const target = ref.current;
|
||||
if (!target)
|
||||
return;
|
||||
const resizeObserver = new ResizeObserver((entries: any) => {
|
||||
const entry = entries[entries.length - 1];
|
||||
if (entry && entry.contentRect)
|
||||
setMeasure(entry.contentRect);
|
||||
});
|
||||
resizeObserver.observe(target);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [ref]);
|
||||
return [measure, ref] as const;
|
||||
}
|
||||
|
||||
export function msToString(ms: number): string {
|
||||
if (!isFinite(ms))
|
||||
return '-';
|
||||
|
|
|
|||
BIN
tests/assets/trace-1.31.zip
Normal file
BIN
tests/assets/trace-1.31.zip
Normal file
Binary file not shown.
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Fixtures, Frame, Locator, Page, Browser, BrowserContext } from '@playwright/test';
|
||||
import type { Fixtures, FrameLocator, Locator, Page, Browser, BrowserContext } from '@playwright/test';
|
||||
import { showTraceViewer } from '../../packages/playwright-core/lib/server';
|
||||
|
||||
type BaseTestFixtures = {
|
||||
|
|
@ -51,7 +51,7 @@ class TraceViewerPage {
|
|||
this.consoleStacks = page.locator('.console-stack');
|
||||
this.stackFrames = page.getByTestId('stack-trace').locator('.list-view-entry');
|
||||
this.networkRequests = page.locator('.network-request-title');
|
||||
this.snapshotContainer = page.locator('.snapshot-container iframe');
|
||||
this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]');
|
||||
}
|
||||
|
||||
async actionIconsText(action: string) {
|
||||
|
|
@ -96,15 +96,11 @@ class TraceViewerPage {
|
|||
return result.sort();
|
||||
}
|
||||
|
||||
async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<Frame> {
|
||||
const existing = this.page.mainFrame().childFrames()[0];
|
||||
await Promise.all([
|
||||
existing ? existing.waitForNavigation() as any : Promise.resolve(),
|
||||
this.selectAction(actionName, ordinal),
|
||||
]);
|
||||
while (this.page.frames().length < (hasSubframe ? 3 : 2))
|
||||
async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<FrameLocator> {
|
||||
await this.selectAction(actionName, ordinal);
|
||||
while (this.page.frames().length < (hasSubframe ? 4 : 3))
|
||||
await this.page.waitForEvent('frameattached');
|
||||
return this.page.mainFrame().childFrames()[0];
|
||||
return this.page.frameLocator('iframe.snapshot-visible[name=snapshot]');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ it.describe('snapshots', () => {
|
|||
await snapshotter.reset();
|
||||
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
|
||||
const html2 = snapshot2.render().html;
|
||||
expect(html2.replace(`"call@2"`, `"call@1"`)).toEqual(html1);
|
||||
expect(html2.replace(/call@2/g, `call@1`)).toEqual(html1);
|
||||
});
|
||||
|
||||
it('should capture resources', async ({ page, toImpl, server, snapshotter }) => {
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ test('should capture iframe with sandbox attribute', async ({ page, server, runA
|
|||
|
||||
// Render snapshot, check expectations.
|
||||
const snapshotFrame = await traceViewer.snapshotFrame('page.evaluate', 0, true);
|
||||
const button = await snapshotFrame.childFrames()[0].waitForSelector('button');
|
||||
const button = snapshotFrame.frameLocator('iframe').locator('button');
|
||||
expect(await button.textContent()).toBe('Hello iframe');
|
||||
});
|
||||
|
||||
|
|
@ -283,8 +283,8 @@ test('should capture data-url svg iframe', async ({ page, server, runAndTrace })
|
|||
|
||||
// Render snapshot, check expectations.
|
||||
const snapshotFrame = await traceViewer.snapshotFrame('page.evaluate', 0, true);
|
||||
await expect(snapshotFrame.childFrames()[0].locator('svg')).toBeVisible();
|
||||
const content = await snapshotFrame.childFrames()[0].content();
|
||||
await expect(snapshotFrame.frameLocator('iframe').locator('svg')).toBeVisible();
|
||||
const content = await snapshotFrame.frameLocator('iframe').locator(':root').innerHTML();
|
||||
expect(content).toContain(`d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"`);
|
||||
});
|
||||
|
||||
|
|
@ -313,19 +313,9 @@ test('should contain adopted style sheets', async ({ page, runAndTrace, browserN
|
|||
});
|
||||
|
||||
const frame = await traceViewer.snapshotFrame('page.evaluate');
|
||||
await frame.waitForSelector('button');
|
||||
const buttonColor = await frame.$eval('button', button => {
|
||||
return window.getComputedStyle(button).color;
|
||||
});
|
||||
expect(buttonColor).toBe('rgb(255, 0, 0)');
|
||||
const divColor = await frame.$eval('div', div => {
|
||||
return window.getComputedStyle(div).color;
|
||||
});
|
||||
expect(divColor).toBe('rgb(0, 0, 255)');
|
||||
const spanColor = await frame.$eval('span', span => {
|
||||
return window.getComputedStyle(span).color;
|
||||
});
|
||||
expect(spanColor).toBe('rgb(0, 0, 255)');
|
||||
await expect(frame.locator('button')).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
await expect(frame.locator('div')).toHaveCSS('color', 'rgb(0, 0, 255)');
|
||||
await expect(frame.locator('span')).toHaveCSS('color', 'rgb(0, 0, 255)');
|
||||
});
|
||||
|
||||
test('should work with adopted style sheets and replace/replaceSync', async ({ page, runAndTrace, browserName }) => {
|
||||
|
|
@ -350,27 +340,15 @@ test('should work with adopted style sheets and replace/replaceSync', async ({ p
|
|||
|
||||
{
|
||||
const frame = await traceViewer.snapshotFrame('page.evaluate', 0);
|
||||
await frame.waitForSelector('button');
|
||||
const buttonColor = await frame.$eval('button', button => {
|
||||
return window.getComputedStyle(button).color;
|
||||
});
|
||||
expect(buttonColor).toBe('rgb(255, 0, 0)');
|
||||
await expect(frame.locator('button')).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
}
|
||||
{
|
||||
const frame = await traceViewer.snapshotFrame('page.evaluate', 1);
|
||||
await frame.waitForSelector('button');
|
||||
const buttonColor = await frame.$eval('button', button => {
|
||||
return window.getComputedStyle(button).color;
|
||||
});
|
||||
expect(buttonColor).toBe('rgb(0, 0, 255)');
|
||||
await expect(frame.locator('button')).toHaveCSS('color', 'rgb(0, 0, 255)');
|
||||
}
|
||||
{
|
||||
const frame = await traceViewer.snapshotFrame('page.evaluate', 2);
|
||||
await frame.waitForSelector('button');
|
||||
const buttonColor = await frame.$eval('button', button => {
|
||||
return window.getComputedStyle(button).color;
|
||||
});
|
||||
expect(buttonColor).toBe('rgb(0, 255, 0)');
|
||||
await expect(frame.locator('button')).toHaveCSS('color', 'rgb(0, 255, 0)');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -402,8 +380,7 @@ test('should restore scroll positions', async ({ page, runAndTrace, browserName
|
|||
|
||||
// Render snapshot, check expectations.
|
||||
const frame = await traceViewer.snapshotFrame('scrollIntoViewIfNeeded');
|
||||
const div = await frame.waitForSelector('div');
|
||||
expect(await div.evaluate(div => div.scrollTop)).toBe(136);
|
||||
expect(await frame.locator('div').evaluate(div => div.scrollTop)).toBe(136);
|
||||
});
|
||||
|
||||
test('should restore control values', async ({ page, runAndTrace }) => {
|
||||
|
|
@ -450,13 +427,10 @@ test('should restore control values', async ({ page, runAndTrace }) => {
|
|||
await expect(textarea).toHaveText('old');
|
||||
await expect(textarea).toHaveValue('hello');
|
||||
|
||||
expect(await frame.$eval('option >> nth=0', o => o.hasAttribute('selected'))).toBe(false);
|
||||
expect(await frame.$eval('option >> nth=1', o => o.hasAttribute('selected'))).toBe(true);
|
||||
expect(await frame.$eval('option >> nth=2', o => o.hasAttribute('selected'))).toBe(false);
|
||||
expect(await frame.locator('select').evaluate(s => {
|
||||
const options = [...(s as HTMLSelectElement).selectedOptions];
|
||||
return options.map(option => option.value);
|
||||
})).toEqual(['opt1', 'opt3']);
|
||||
expect(await frame.locator('option >> nth=0').evaluate(o => o.hasAttribute('selected'))).toBe(false);
|
||||
expect(await frame.locator('option >> nth=1').evaluate(o => o.hasAttribute('selected'))).toBe(true);
|
||||
expect(await frame.locator('option >> nth=2').evaluate(o => o.hasAttribute('selected'))).toBe(false);
|
||||
await expect(frame.locator('select')).toHaveValues(['opt1', 'opt3']);
|
||||
});
|
||||
|
||||
test('should work with meta CSP', async ({ page, runAndTrace, browserName }) => {
|
||||
|
|
@ -479,9 +453,8 @@ test('should work with meta CSP', async ({ page, runAndTrace, browserName }) =>
|
|||
|
||||
// Render snapshot, check expectations.
|
||||
const frame = await traceViewer.snapshotFrame('$eval');
|
||||
await frame.waitForSelector('div');
|
||||
// Should render shadow dom with post-processing script.
|
||||
expect(await frame.textContent('span')).toBe('World');
|
||||
await expect(frame.locator('span')).toHaveText('World');
|
||||
});
|
||||
|
||||
test('should handle multiple headers', async ({ page, server, runAndTrace, browserName }) => {
|
||||
|
|
@ -497,9 +470,8 @@ test('should handle multiple headers', async ({ page, server, runAndTrace, brows
|
|||
});
|
||||
|
||||
const frame = await traceViewer.snapshotFrame('setContent');
|
||||
await frame.waitForSelector('div');
|
||||
const padding = await frame.$eval('body', body => window.getComputedStyle(body).paddingLeft);
|
||||
expect(padding).toBe('42px');
|
||||
await frame.locator('div').waitFor();
|
||||
await expect(frame.locator('body')).toHaveCSS('padding-left', '42px');
|
||||
});
|
||||
|
||||
test('should handle src=blob', async ({ page, server, runAndTrace, browserName }) => {
|
||||
|
|
@ -521,14 +493,12 @@ test('should handle src=blob', async ({ page, server, runAndTrace, browserName }
|
|||
});
|
||||
|
||||
const frame = await traceViewer.snapshotFrame('page.evaluate');
|
||||
const img = await frame.waitForSelector('img');
|
||||
const size = await img.evaluate(e => (e as HTMLImageElement).naturalWidth);
|
||||
const size = await frame.locator('img').evaluate(e => (e as HTMLImageElement).naturalWidth);
|
||||
expect(size).toBe(10);
|
||||
});
|
||||
|
||||
test('should register custom elements', async ({ page, server, runAndTrace }) => {
|
||||
const traceViewer = await runAndTrace(async () => {
|
||||
page.on('console', console.log);
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await page.evaluate(() => {
|
||||
customElements.define('my-element', class extends HTMLElement {
|
||||
|
|
@ -719,6 +689,19 @@ test('should include requestUrl in route.fulfill', async ({ page, runAndTrace, b
|
|||
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
|
||||
});
|
||||
|
||||
test('should not crash with broken locator', async ({ page, runAndTrace, server }) => {
|
||||
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/21832' });
|
||||
const traceViewer = await runAndTrace(async () => {
|
||||
try {
|
||||
await page.locator('[class*=github-btn] a]').click();
|
||||
} catch (e) {
|
||||
}
|
||||
});
|
||||
await expect(traceViewer.page).toHaveTitle('Playwright Trace Viewer');
|
||||
const header = traceViewer.page.getByText('Playwright', { exact: true });
|
||||
await expect(header).toBeVisible();
|
||||
});
|
||||
|
||||
test('should include requestUrl in route.continue', async ({ page, runAndTrace, server }) => {
|
||||
await page.route('**/*', route => {
|
||||
route.continue({ url: server.EMPTY_PAGE });
|
||||
|
|
@ -767,8 +750,7 @@ test('should serve overridden request', async ({ page, runAndTrace, server }) =>
|
|||
});
|
||||
// Render snapshot, check expectations.
|
||||
const snapshotFrame = await traceViewer.snapshotFrame('page.goto');
|
||||
const color = await snapshotFrame.locator('body').evaluate(body => getComputedStyle(body).backgroundColor);
|
||||
expect(color).toBe('rgb(255, 0, 0)');
|
||||
await expect(snapshotFrame.locator('body')).toHaveCSS('background-color', 'rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should display waitForLoadState even if did not wait for it', async ({ runAndTrace, server, page }) => {
|
||||
|
|
@ -803,7 +785,7 @@ test('should pick locator', async ({ page, runAndTrace, server }) => {
|
|||
});
|
||||
const snapshot = await traceViewer.snapshotFrame('page.setContent');
|
||||
await traceViewer.page.getByTitle('Pick locator').click();
|
||||
await snapshot.click('button');
|
||||
await snapshot.locator('button').click();
|
||||
await expect(traceViewer.page.locator('.cm-wrapper')).toContainText(`getByRole('button', { name: 'Submit' })`);
|
||||
});
|
||||
|
||||
|
|
@ -818,3 +800,9 @@ test('should update highlight when typing', async ({ page, runAndTrace, server }
|
|||
await traceViewer.page.keyboard.type('button');
|
||||
await expect(snapshot.locator('x-pw-glass')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open trace-1.31', async ({ showTraceViewer }) => {
|
||||
const traceViewer = await showTraceViewer([path.join(__dirname, '../assets/trace-1.31.zip')]);
|
||||
const snapshot = await traceViewer.snapshotFrame('locator.click');
|
||||
await expect(snapshot.locator('[__playwright_target__]')).toHaveText(['Submit']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,32 +75,3 @@ test('should treat enums equally', async ({ runInlineTest }) => {
|
|||
expect(result.exitCode).toBe(0);
|
||||
expect(result.passed).toBe(1);
|
||||
});
|
||||
|
||||
test('should allow declare fields', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'example.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
class Base {
|
||||
constructor(p1, p2) {
|
||||
this.p1 = p1;
|
||||
this.p2 = p2;
|
||||
}
|
||||
}
|
||||
|
||||
class Derived extends Base {
|
||||
p1: string;
|
||||
declare p2: string;
|
||||
}
|
||||
|
||||
test('works', () => {
|
||||
const d = new Derived('value1', 'value2');
|
||||
expect(d.p1).toBe(undefined);
|
||||
expect(d.p2).toBe('value2');
|
||||
})
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.passed).toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -163,7 +163,6 @@ test('should exit with code 0 with --pass-with-no-tests', async ({ runInlineTest
|
|||
`,
|
||||
}, undefined, undefined, { additionalArgs: ['--pass-with-no-tests'] });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(`Running 0 tests using 0 workers`);
|
||||
});
|
||||
|
||||
test('should exit with code 1 when config is not found', async ({ runInlineTest }) => {
|
||||
|
|
|
|||
|
|
@ -35,17 +35,10 @@ test('should retry predicate', async ({ runInlineTest }) => {
|
|||
}).toPass();
|
||||
expect(i).toBe(3);
|
||||
});
|
||||
test('should retry expect.soft assertions', async () => {
|
||||
let i = 0;
|
||||
await test.expect(() => {
|
||||
expect.soft(++i).toBe(3);
|
||||
}).toPass();
|
||||
expect(i).toBe(3);
|
||||
});
|
||||
`
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.passed).toBe(3);
|
||||
expect(result.passed).toBe(2);
|
||||
});
|
||||
|
||||
test('should respect timeout', async ({ runInlineTest }) => {
|
||||
|
|
@ -159,74 +152,12 @@ test('should use custom message', async ({ runInlineTest }) => {
|
|||
expect(result.failed).toBe(1);
|
||||
});
|
||||
|
||||
test('should swallow all soft errors inside toPass matcher, if successful', async ({ runInlineTest }) => {
|
||||
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/20437' });
|
||||
|
||||
const result = await runInlineTest({
|
||||
'a.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('should respect soft', async () => {
|
||||
expect.soft('before-toPass').toBe('zzzz');
|
||||
let i = 0;
|
||||
await test.expect(() => {
|
||||
++i;
|
||||
expect.soft('inside-toPass-' + i).toBe('inside-toPass-2');
|
||||
}).toPass({ timeout: 1000 });
|
||||
expect.soft('after-toPass').toBe('zzzz');
|
||||
});
|
||||
`
|
||||
});
|
||||
expect(result.output).toContain('Received: "before-toPass"');
|
||||
expect(result.output).toContain('Received: "after-toPass"');
|
||||
expect(result.output).not.toContain('Received: "inside-toPass-1"');
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
});
|
||||
|
||||
test('should work with no.toPass and failing soft assertion', async ({ runInlineTest }) => {
|
||||
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/20518' });
|
||||
|
||||
const result = await runInlineTest({
|
||||
'a.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('should work', async () => {
|
||||
await test.expect(() => {
|
||||
expect.soft(1).toBe(2);
|
||||
}).not.toPass({ timeout: 1000 });
|
||||
});
|
||||
`
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.passed).toBe(1);
|
||||
});
|
||||
|
||||
test('should show only soft errors on last toPass pass', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'a.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('should respect soft', async () => {
|
||||
let i = 0;
|
||||
await test.expect(() => {
|
||||
++i;
|
||||
expect.soft('inside-toPass-' + i).toBe('0');
|
||||
}).toPass({ timeout: 1000, intervals: [100, 100, 100000] });
|
||||
});
|
||||
`
|
||||
});
|
||||
expect(result.output).not.toContain('Received: "inside-toPass-1"');
|
||||
expect(result.output).not.toContain('Received: "inside-toPass-2"');
|
||||
expect(result.output).toContain('Received: "inside-toPass-3"');
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
});
|
||||
|
||||
test('should work with soft', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'a.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('should respect soft', async () => {
|
||||
await test.expect.soft(() => {
|
||||
await expect.soft(() => {
|
||||
expect(1).toBe(3);
|
||||
}).toPass({ timeout: 1000 });
|
||||
expect.soft(2).toBe(3);
|
||||
|
|
|
|||
63
tests/playwright-test/playwright.reuse.browser.spec.ts
Normal file
63
tests/playwright-test/playwright.reuse.browser.spec.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -132,6 +132,42 @@ test('should not throw with trace: on-first-retry and two retries in the same wo
|
|||
expect(result.flaky).toBe(6);
|
||||
});
|
||||
|
||||
test('should not mixup network files between contexts', async ({ runInlineTest, server }, testInfo) => {
|
||||
// NOTE: this test reproduces the issue 10% of the time. Running with --repeat-each=20 helps.
|
||||
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22089' });
|
||||
|
||||
const result = await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
export default { use: { trace: 'on' } };
|
||||
`,
|
||||
'a.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
let page1, page2;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page1 = await browser.newPage();
|
||||
await page1.goto("${server.EMPTY_PAGE}");
|
||||
|
||||
page2 = await browser.newPage();
|
||||
await page2.goto("${server.EMPTY_PAGE}");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page1.close();
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test('example', async ({ page }) => {
|
||||
await page.goto("${server.EMPTY_PAGE}");
|
||||
});
|
||||
`,
|
||||
}, { workers: 1, timeout: 15000 });
|
||||
expect(result.exitCode).toEqual(0);
|
||||
expect(result.passed).toBe(1);
|
||||
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-example', 'trace.zip'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should save sources when requested', async ({ runInlineTest }, testInfo) => {
|
||||
const result = await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
|
|
@ -286,3 +322,21 @@ test('should respect --trace', async ({ runInlineTest }, testInfo) => {
|
|||
expect(result.passed).toBe(1);
|
||||
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInfo) => {
|
||||
const result = await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
export default { use: { trace: 'on' } };
|
||||
`,
|
||||
'a.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('test 1', async ({ page }) => {
|
||||
await page.goto('about:blank');
|
||||
});
|
||||
`,
|
||||
}, {}, { PW_TEST_DISABLE_TRACING: '1' });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.passed).toBe(1);
|
||||
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,13 +21,21 @@ import type { TestChildProcess } from '../config/commonFixtures';
|
|||
import { cleanEnv, cliEntrypoint, removeFolderAsync, test as base, writeFiles } from './playwright-test-fixtures';
|
||||
import type { Files, RunOptions } from './playwright-test-fixtures';
|
||||
import type { Browser, Page, TestInfo } from './stable-test-runner';
|
||||
import { createGuid } from '../../packages/playwright-core/src/utils/crypto';
|
||||
|
||||
type Latch = {
|
||||
blockingCode: string;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type Fixtures = {
|
||||
runUITest: (files: Files, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<Page>;
|
||||
createLatch: () => Latch;
|
||||
};
|
||||
|
||||
export function dumpTestTree(page: Page): () => Promise<string> {
|
||||
return () => page.getByTestId('test-tree').evaluate(async treeElement => {
|
||||
export function dumpTestTree(page: Page, options: { time?: boolean } = {}): () => Promise<string> {
|
||||
return () => page.getByTestId('test-tree').evaluate(async (treeElement, options) => {
|
||||
function iconName(iconElement: Element): string {
|
||||
const icon = iconElement.className.replace('codicon codicon-', '');
|
||||
if (icon === 'chevron-right')
|
||||
|
|
@ -48,6 +56,8 @@ export function dumpTestTree(page: Page): () => Promise<string> {
|
|||
return '👁';
|
||||
if (icon === 'loading')
|
||||
return '↻';
|
||||
if (icon === 'clock')
|
||||
return '🕦';
|
||||
return icon;
|
||||
}
|
||||
|
||||
|
|
@ -60,23 +70,27 @@ export function dumpTestTree(page: Page): () => Promise<string> {
|
|||
const indent = listItem.querySelectorAll('.list-view-indent').length;
|
||||
const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : '';
|
||||
const selected = listItem.classList.contains('selected') ? ' <=' : '';
|
||||
result.push(' ' + ' '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + listItem.textContent + watch + selected);
|
||||
const title = listItem.querySelector('.watch-mode-list-item-title').textContent;
|
||||
const timeElement = options.time ? listItem.querySelector('.watch-mode-list-item-time') : undefined;
|
||||
const time = timeElement ? ' ' + timeElement.textContent.replace(/\d+m?s/, 'XXms') : '';
|
||||
result.push(' ' + ' '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + title + time + watch + selected);
|
||||
}
|
||||
return '\n' + result.join('\n') + '\n ';
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
export const test = base
|
||||
.extend<Fixtures>({
|
||||
runUITest: async ({ childProcess, playwright, headless }, use, testInfo: TestInfo) => {
|
||||
testInfo.slow();
|
||||
if (process.env.CI)
|
||||
testInfo.slow();
|
||||
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-'));
|
||||
let testProcess: TestChildProcess | undefined;
|
||||
let browser: Browser | undefined;
|
||||
await use(async (files: Files, env: NodeJS.ProcessEnv = {}, options: RunOptions = {}) => {
|
||||
const baseDir = await writeFiles(testInfo, files, true);
|
||||
testProcess = childProcess({
|
||||
command: ['node', cliEntrypoint, 'ui', '--workers=1', ...(options.additionalArgs || [])],
|
||||
command: ['node', cliEntrypoint, 'test', '--ui', '--workers=1', ...(options.additionalArgs || [])],
|
||||
env: {
|
||||
...cleanEnv(env),
|
||||
PWTEST_UNDER_TEST: '1',
|
||||
|
|
@ -98,6 +112,22 @@ export const test = base
|
|||
await testProcess?.close();
|
||||
await removeFolderAsync(cacheDir);
|
||||
},
|
||||
createLatch: async ({}, use, testInfo) => {
|
||||
await use(() => {
|
||||
const latchFile = path.join(testInfo.project.outputDir, createGuid() + '.latch');
|
||||
return {
|
||||
blockingCode: `await ((${waitForLatch})(${JSON.stringify(latchFile)}))`,
|
||||
open: () => fs.writeFileSync(latchFile, 'ok'),
|
||||
close: () => fs.unlinkSync(latchFile),
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from './stable-test-runner';
|
||||
|
||||
async function waitForLatch(latchFile: string) {
|
||||
const fs = require('fs');
|
||||
while (!fs.existsSync(latchFile))
|
||||
await new Promise(f => setTimeout(f, 250));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,3 +148,58 @@ test('should filter by project', async ({ runUITest }) => {
|
|||
|
||||
await expect(page.getByText('Projects: foo bar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not hide filtered while running', async ({ runUITest, createLatch }) => {
|
||||
const latch = createLatch();
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
test('fails', async () => {
|
||||
${latch.blockingCode}
|
||||
expect(1).toBe(2);
|
||||
});
|
||||
`,
|
||||
});
|
||||
await page.getByTitle('Run all').click();
|
||||
latch.open();
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ❌ a.test.ts
|
||||
✅ passes
|
||||
❌ fails <=
|
||||
`);
|
||||
|
||||
latch.close();
|
||||
await page.getByText('Status:').click();
|
||||
await page.getByLabel('failed').setChecked(true);
|
||||
await page.getByTitle('Run all').click();
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ↻ a.test.ts
|
||||
↻ fails <=
|
||||
`);
|
||||
});
|
||||
|
||||
test('should filter skipped', async ({ runUITest, createLatch }) => {
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
test.skip('fails', async () => {
|
||||
expect(1).toBe(2);
|
||||
});
|
||||
`,
|
||||
});
|
||||
await page.getByTitle('Run all').click();
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ✅ a.test.ts
|
||||
✅ passes
|
||||
⊘ fails
|
||||
`);
|
||||
|
||||
await page.getByText('Status:').click();
|
||||
await page.getByLabel('skipped').setChecked(true);
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ⊘ a.test.ts
|
||||
⊘ fails
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
58
tests/playwright-test/ui-mode-test-output.spec.ts
Normal file
58
tests/playwright-test/ui-mode-test-output.spec.ts
Normal 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`);
|
||||
});
|
||||
|
|
@ -73,9 +73,9 @@ test('should update trace live', async ({ runUITest, server }) => {
|
|||
onePromise.resolve();
|
||||
|
||||
await expect(
|
||||
page.frameLocator('id=snapshot').locator('body'),
|
||||
page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('body'),
|
||||
'verify snapshot'
|
||||
).toHaveText('One');
|
||||
).toHaveText('One', { timeout: 15000 });
|
||||
await expect(listItem).toHaveText([
|
||||
/browserContext.newPage[\d.]+m?s/,
|
||||
/page.gotohttp:\/\/localhost:\d+\/one.html[\d.]+m?s/,
|
||||
|
|
@ -99,7 +99,7 @@ test('should update trace live', async ({ runUITest, server }) => {
|
|||
twoPromise.resolve();
|
||||
|
||||
await expect(
|
||||
page.frameLocator('id=snapshot').locator('body'),
|
||||
page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('body'),
|
||||
'verify snapshot'
|
||||
).toHaveText('Two');
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,27 @@ test('should run visible', async ({ runUITest }) => {
|
|||
✅ passes
|
||||
⊘ skipped
|
||||
`);
|
||||
|
||||
await expect(page.getByTestId('status-line')).toHaveText('4/8 passed (50%)');
|
||||
});
|
||||
|
||||
test('should show running progress', async ({ runUITest }) => {
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('test 1', async () => {});
|
||||
test('test 2', async () => new Promise(() => {}));
|
||||
test('test 3', async () => {});
|
||||
test('test 4', async () => {});
|
||||
`,
|
||||
});
|
||||
|
||||
await page.getByTitle('Run all').click();
|
||||
await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)', { timeout: 15000 });
|
||||
await page.getByTitle('Stop').click();
|
||||
await expect(page.getByTestId('status-line')).toHaveText('1/4 passed (25%)', { timeout: 15000 });
|
||||
await page.getByTitle('Reload').click();
|
||||
await expect(page.getByTestId('status-line')).toBeHidden();
|
||||
});
|
||||
|
||||
test('should run on hover', async ({ runUITest }) => {
|
||||
|
|
@ -207,7 +228,7 @@ test('should stop', async ({ runUITest }) => {
|
|||
`,
|
||||
});
|
||||
|
||||
await expect(page.getByTitle('Run all')).toBeEnabled();
|
||||
await expect(page.getByTitle('Run all')).toBeEnabled({ timeout: 15000 });
|
||||
await expect(page.getByTitle('Stop')).toBeDisabled();
|
||||
|
||||
await page.getByTitle('Run all').click();
|
||||
|
|
@ -217,7 +238,7 @@ test('should stop', async ({ runUITest }) => {
|
|||
⊘ test 0
|
||||
✅ test 1
|
||||
↻ test 2
|
||||
↻ test 3
|
||||
🕦 test 3
|
||||
`);
|
||||
|
||||
await expect(page.getByTitle('Run all')).toBeDisabled();
|
||||
|
|
@ -233,3 +254,55 @@ test('should stop', async ({ runUITest }) => {
|
|||
◯ test 3
|
||||
`);
|
||||
});
|
||||
|
||||
test('should run folder', async ({ runUITest }) => {
|
||||
const page = await runUITest({
|
||||
'a/folder-b/folder-c/inC.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
`,
|
||||
'a/folder-b/in-b.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
`,
|
||||
'a/in-a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
`,
|
||||
});
|
||||
|
||||
await page.getByText('folder-b').hover();
|
||||
await page.getByRole('listitem').filter({ hasText: 'folder-b' }).getByTitle('Run').click();
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
|
||||
▼ ✅ folder-b <=
|
||||
► ✅ folder-c
|
||||
► ✅ in-b.test.ts
|
||||
▼ ◯ in-a.test.ts
|
||||
◯ passes
|
||||
`);
|
||||
});
|
||||
|
||||
test('should show time', async ({ runUITest }) => {
|
||||
const page = await runUITest(basicTestTree);
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
|
||||
▼ ◯ a.test.ts
|
||||
`);
|
||||
|
||||
await page.getByTitle('Run all').click();
|
||||
|
||||
await expect.poll(dumpTestTree(page, { time: true }), { timeout: 15000 }).toBe(`
|
||||
▼ ❌ a.test.ts
|
||||
✅ passes XXms
|
||||
❌ fails XXms <=
|
||||
► ❌ suite
|
||||
▼ ❌ b.test.ts
|
||||
✅ passes XXms
|
||||
❌ fails XXms
|
||||
▼ ✅ c.test.ts
|
||||
✅ passes XXms
|
||||
⊘ skipped
|
||||
`);
|
||||
|
||||
await expect(page.getByTestId('status-line')).toHaveText('4/8 passed (50%)');
|
||||
});
|
||||
|
|
|
|||
66
tests/playwright-test/ui-mode-test-source.spec.ts
Normal file
66
tests/playwright-test/ui-mode-test-source.spec.ts
Normal 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', () => {});`);
|
||||
});
|
||||
|
|
@ -85,7 +85,7 @@ test('should traverse up/down', async ({ runUITest }) => {
|
|||
test('should expand / collapse groups', async ({ runUITest }) => {
|
||||
const page = await runUITest(basicTestTree);
|
||||
|
||||
await page.getByText('suite').click();
|
||||
await page.getByTestId('test-tree').getByText('suite').click();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
|
||||
▼ ◯ a.test.ts
|
||||
|
|
@ -104,7 +104,7 @@ test('should expand / collapse groups', async ({ runUITest }) => {
|
|||
► ◯ suite <=
|
||||
`);
|
||||
|
||||
await page.getByText('passes').first().click();
|
||||
await page.getByTestId('test-tree').getByText('passes').first().click();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
|
||||
▼ ◯ a.test.ts <=
|
||||
|
|
@ -117,3 +117,108 @@ test('should expand / collapse groups', async ({ runUITest }) => {
|
|||
► ◯ a.test.ts <=
|
||||
`);
|
||||
});
|
||||
|
||||
test('should merge folder trees', async ({ runUITest }) => {
|
||||
const page = await runUITest({
|
||||
'a/b/c/inC.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
`,
|
||||
'a/b/in-b.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
`,
|
||||
'a/in-a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
`,
|
||||
});
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
|
||||
▼ ◯ b
|
||||
► ◯ c
|
||||
► ◯ in-b.test.ts
|
||||
▼ ◯ in-a.test.ts
|
||||
◯ passes
|
||||
`);
|
||||
});
|
||||
|
||||
test('should list parametrized tests', async ({ runUITest }) => {
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `
|
||||
import { test } from '@playwright/test';
|
||||
test.describe('cookies', () => {
|
||||
for (const country of ['FR', 'DE', 'LT']) {
|
||||
test.describe(() => {
|
||||
test('test ' + country, async ({}) => {});
|
||||
});
|
||||
}
|
||||
})
|
||||
`
|
||||
});
|
||||
|
||||
await page.getByText('cookies').click();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.getByText('<anonymous>').click();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
▼ ◯ cookies
|
||||
▼ ◯ <anonymous> <=
|
||||
◯ test FR
|
||||
◯ test DE
|
||||
◯ test LT
|
||||
`);
|
||||
});
|
||||
|
||||
test('should update parametrized tests', async ({ runUITest, writeFiles }) => {
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `
|
||||
import { test } from '@playwright/test';
|
||||
test.describe('cookies', () => {
|
||||
for (const country of ['FR', 'DE', 'LT']) {
|
||||
test.describe(() => {
|
||||
test('test ' + country, async ({}) => {});
|
||||
});
|
||||
}
|
||||
})
|
||||
`
|
||||
});
|
||||
|
||||
await page.getByText('cookies').click();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.getByText('<anonymous>').click();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
▼ ◯ cookies
|
||||
▼ ◯ <anonymous> <=
|
||||
◯ test FR
|
||||
◯ test DE
|
||||
◯ test LT
|
||||
`);
|
||||
|
||||
writeFiles({
|
||||
'a.test.ts': `
|
||||
import { test } from '@playwright/test';
|
||||
test.describe('cookies', () => {
|
||||
for (const country of ['FR', 'LT']) {
|
||||
test.describe(() => {
|
||||
test('test ' + country, async ({}) => {});
|
||||
});
|
||||
}
|
||||
})
|
||||
`
|
||||
});
|
||||
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
▼ ◯ cookies
|
||||
▼ ◯ <anonymous> <=
|
||||
◯ test FR
|
||||
◯ test LT
|
||||
`);
|
||||
});
|
||||
|
|
@ -168,3 +168,62 @@ test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, d
|
|||
◯ inner fails
|
||||
`);
|
||||
});
|
||||
|
||||
test('should update test locations', async ({ runUITest, writeFiles, deleteFile }) => {
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('passes', () => {});
|
||||
`,
|
||||
});
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ passes
|
||||
`);
|
||||
|
||||
const messages: any = [];
|
||||
await page.exposeBinding('_overrideProtocolForTest', (_, data) => messages.push(data));
|
||||
|
||||
const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' });
|
||||
await passesItemLocator.hover();
|
||||
await passesItemLocator.getByTitle('Open in VS Code').click();
|
||||
|
||||
expect(messages).toEqual([{
|
||||
method: 'open',
|
||||
params: {
|
||||
location: expect.stringContaining('a.test.ts:3'),
|
||||
},
|
||||
}]);
|
||||
|
||||
await writeFiles({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('new-test', () => {});
|
||||
|
||||
test('passes', () => {});
|
||||
`
|
||||
});
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ new-test
|
||||
◯ passes <=
|
||||
`);
|
||||
|
||||
messages.length = 0;
|
||||
await passesItemLocator.hover();
|
||||
await passesItemLocator.getByTitle('Open in VS Code').click();
|
||||
|
||||
expect(messages).toEqual([{
|
||||
method: 'open',
|
||||
params: {
|
||||
location: expect.stringContaining('a.test.ts:5'),
|
||||
},
|
||||
}]);
|
||||
|
||||
await expect(
|
||||
page.getByTestId('source-code').locator('.source-tab-file-name')
|
||||
).toHaveText('a.test.ts');
|
||||
await expect(page.locator('.CodeMirror-code')).toContainText(`3 test('new-test', () => {});`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
|
|||
|
||||
await page.getByText('fails').click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Watch').click();
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ passes
|
||||
◯ fails 👁 <=
|
||||
`);
|
||||
|
||||
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Run').click();
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
|
|
@ -51,3 +57,208 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
|
|||
✅ fails 👁 <=
|
||||
`);
|
||||
});
|
||||
|
||||
test('should watch e2e deps', async ({ runUITest, writeFiles }) => {
|
||||
const page = await runUITest({
|
||||
'playwright.config.ts': `
|
||||
import { defineConfig } from '@playwright/test';
|
||||
export default defineConfig({ testDir: 'tests' });
|
||||
`,
|
||||
'src/helper.ts': `
|
||||
export const answer = 41;
|
||||
`,
|
||||
'tests/a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { answer } from '../src/helper';
|
||||
test('answer', () => { expect(answer).toBe(42); });
|
||||
`,
|
||||
});
|
||||
|
||||
await page.getByText('answer').click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'answer' }).getByTitle('Watch').click();
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ answer 👁 <=
|
||||
`);
|
||||
|
||||
await writeFiles({
|
||||
'src/helper.ts': `
|
||||
export const answer = 42;
|
||||
`
|
||||
});
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ✅ a.test.ts
|
||||
✅ answer 👁 <=
|
||||
`);
|
||||
});
|
||||
|
||||
test('should batch watch updates', async ({ runUITest, writeFiles }) => {
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
await page.getByText('a.test.ts').click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'a.test.ts' }).getByTitle('Watch').click();
|
||||
await page.getByText('b.test.ts').click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'b.test.ts' }).getByTitle('Watch').click();
|
||||
await page.getByText('c.test.ts').click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'c.test.ts' }).getByTitle('Watch').click();
|
||||
await page.getByText('d.test.ts').click();
|
||||
await page.getByRole('listitem').filter({ hasText: 'd.test.ts' }).getByTitle('Watch').click();
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts 👁
|
||||
◯ test
|
||||
▼ ◯ b.test.ts 👁
|
||||
◯ test
|
||||
▼ ◯ c.test.ts 👁
|
||||
◯ test
|
||||
▼ ◯ d.test.ts 👁 <=
|
||||
◯ test
|
||||
`);
|
||||
|
||||
await writeFiles({
|
||||
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('status-line')).toHaveText('4/4 passed (100%)', { timeout: 15000 });
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ✅ a.test.ts 👁
|
||||
✅ test
|
||||
▼ ✅ b.test.ts 👁
|
||||
✅ test
|
||||
▼ ✅ c.test.ts 👁
|
||||
✅ test
|
||||
▼ ✅ d.test.ts 👁 <=
|
||||
✅ test
|
||||
`);
|
||||
});
|
||||
|
||||
test('should watch all', async ({ runUITest, writeFiles }) => {
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ test
|
||||
▼ ◯ b.test.ts
|
||||
◯ test
|
||||
▼ ◯ c.test.ts
|
||||
◯ test
|
||||
▼ ◯ d.test.ts
|
||||
◯ test
|
||||
`);
|
||||
await page.getByTitle('Watch all').click();
|
||||
|
||||
await writeFiles({
|
||||
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('status-line')).toHaveText('2/2 passed (100%)', { timeout: 15000 });
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ✅ a.test.ts
|
||||
✅ test
|
||||
▼ ◯ b.test.ts
|
||||
◯ test
|
||||
▼ ◯ c.test.ts
|
||||
◯ test
|
||||
▼ ✅ d.test.ts
|
||||
✅ test
|
||||
`);
|
||||
});
|
||||
|
||||
test('should watch new file', async ({ runUITest, writeFiles }) => {
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
await page.getByTitle('Watch all').click();
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ test
|
||||
`);
|
||||
|
||||
// First time add file.
|
||||
await writeFiles({
|
||||
'b.test.ts': ` import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ test
|
||||
▼ ◯ b.test.ts
|
||||
◯ test
|
||||
`);
|
||||
|
||||
// Second time run file.
|
||||
await writeFiles({
|
||||
'b.test.ts': ` import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)', { timeout: 15000 });
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ test
|
||||
▼ ✅ b.test.ts
|
||||
✅ test
|
||||
`);
|
||||
});
|
||||
|
||||
test('should queue watches', async ({ runUITest, writeFiles, createLatch }) => {
|
||||
const latch = createLatch();
|
||||
const page = await runUITest({
|
||||
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'b.test.ts': `import { test } from '@playwright/test'; test('test', async () => {
|
||||
${latch.blockingCode}
|
||||
});`,
|
||||
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'd.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
|
||||
▼ ◯ a.test.ts
|
||||
◯ test
|
||||
▼ ◯ b.test.ts
|
||||
◯ test
|
||||
▼ ◯ c.test.ts
|
||||
◯ test
|
||||
▼ ◯ d.test.ts
|
||||
◯ test
|
||||
`);
|
||||
|
||||
await page.getByTitle('Watch all').click();
|
||||
await page.getByTitle('Run all').click();
|
||||
|
||||
await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)', { timeout: 15000 });
|
||||
|
||||
await writeFiles({
|
||||
'a.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'b.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
'c.test.ts': `import { test } from '@playwright/test'; test('test', () => {});`,
|
||||
});
|
||||
|
||||
// Now watches should not kick in.
|
||||
await new Promise(f => setTimeout(f, 1000));
|
||||
await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)', { timeout: 15000 });
|
||||
|
||||
// Allow test to finish and new watch to kick in.
|
||||
latch.open();
|
||||
|
||||
await expect(page.getByTestId('status-line')).toHaveText('3/3 passed (100%)', { timeout: 15000 });
|
||||
});
|
||||
|
|
|
|||
95
tests/playwright-test/ui-mode-trace.spec.ts
Normal file
95
tests/playwright-test/ui-mode-trace.spec.ts
Normal 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');
|
||||
});
|
||||
|
|
@ -457,6 +457,32 @@ test('should send Accept header', async ({ runInlineTest, server }) => {
|
|||
expect(acceptHeader).toBe('*/*');
|
||||
});
|
||||
|
||||
test('should follow redirects', async ({ runInlineTest, server }) => {
|
||||
server.setRedirect('/redirect', '/redirected-to');
|
||||
server.setRoute('/redirected-to', (req, res) => {
|
||||
res.end('<html><body>hello</body></html>');
|
||||
});
|
||||
const result = await runInlineTest({
|
||||
'test.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('connect to the server', async ({baseURL, page}) => {
|
||||
await page.goto('http://localhost:${server.PORT}/redirect');
|
||||
expect(await page.textContent('body')).toBe('hello');
|
||||
});
|
||||
`,
|
||||
'playwright.config.ts': `
|
||||
module.exports = {
|
||||
webServer: {
|
||||
command: 'node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${server.PORT}',
|
||||
url: 'http://localhost:${server.PORT}/redirect',
|
||||
reuseExistingServer: true,
|
||||
}
|
||||
};
|
||||
`,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should create multiple servers', async ({ runInlineTest }, { workerIndex }) => {
|
||||
const port = workerIndex * 2 + 10500;
|
||||
const result = await runInlineTest({
|
||||
|
|
|
|||
Loading…
Reference in a new issue