Compare commits

...

27 commits

Author SHA1 Message Date
Andrey Lushnikov 0f359b9840
chore: mark v1.20.2 (#13226) 2022-03-31 15:46:06 -07:00
Dmitry Gozman 88e0a3b72c
cherry-pick(#13222): fix(screenshot): do not stall on hideHiglight attempt 2 (#13228)
It turns out that "non stalling evaluate" can stall in Chromium
in some weird conditions, like `document.open` after some weird
`iframe.src` value.

We now only hide highlight in those frames where we did install
highlight in the first place.
2022-03-31 15:45:39 -07:00
Dmitry Gozman 4eb6fca149
cherry-pick(#13137): fix(esm): make sure import from './foo.js' is supported (#13160)
Drive-by: migrate all @esm tests to esm.spec.ts.
2022-03-29 14:46:25 -07:00
Dmitry Gozman e55a3ffbe0
cherry-pick(#13138): fix(parallel): minimize the number of beforeAll/afterAll hooks in parallel mode (#13140)
Previously, we always formed groups consisting of a single test.
Now, we group tests that share `beforeAll`/`afterAll` hooks into
`config.workers` equally-sized groups.
2022-03-29 13:51:30 -07:00
Max Schmitt 18de7bd61d
cherry-pick(#12992): chore: disable Chromium CertificateTransparencyComponentUpdater feature (#12993) 2022-03-23 19:49:37 +01:00
Andrey Lushnikov cfa7f13e43
cherry-pick(#12946): fix: upgrade json5 dependency (#12956)
Fixes #12940

Co-authored-by: Max Schmitt <max@schmitt.mx>
2022-03-22 11:46:44 -07:00
Andrey Lushnikov 8e98b44b96
chore: mark v1.20.1 (#12935) 2022-03-21 17:54:58 -07:00
Andrey Lushnikov 0093e8a753
cherry-pick(#12934): allow toMatchSnapshot to use text comparator for text data (#12938)
This was regressed awhile ago.

In v1.17 we shipped the following code: 30e15ad36f/packages/playwright-test/src/matchers/golden.ts (L122-L131)

`toMatchSnapshot` should fallback to text comparator in case of
unknown extension and string data.

Fixes #12862
2022-03-21 17:54:40 -07:00
Yury Semikhatsky c090a55828
cherry-pick(#12892): docs(fetch): clarify cookie handling (#12931) 2022-03-21 14:56:14 -07:00
Dmitry Gozman 1b0087ed38
cherry-pick(#12888): fix(click): workaround elementsFromPoint issue in Chromium (#12925)
Block elements inside elements are not returned correctly
from `document.elementsFromPoint(x, y)` in some cases.
See https://bugs.chromium.org/p/chromium/issues/detail?id=1307458 for details.
2022-03-21 11:03:56 -07:00
Pavel Feldman ef40d21945 cherry-pick(#12899): chore: make count() work with _vue selectors 2022-03-19 15:21:18 -07:00
Max Schmitt 39abfd6481
cherry-pick(#12864): chore: fix modification dates in driver (#12875) 2022-03-18 09:36:27 -07:00
Max Schmitt f2bdeb29ef
cherry-pick(#12820): chore: restore expect.any()/expect.anything() (#12849) 2022-03-17 21:37:46 +01:00
Henrik Storck 78f8852f7d cherry-pick(#12837): fix: folder traversal for isModule check 2022-03-16 16:56:01 -07:00
Dmitry Gozman 88eba741ad
cherr-pick(#12764): fix(screenshot): do not stall on hideHighlight (#12802) 2022-03-15 18:26:29 -07:00
Pavel Feldman 3dff476a92 cherry-pick(#12801): docs: fix grep release notes 2022-03-15 17:31:30 -07:00
Max Schmitt cd07b45809
cherry-pick(1.19): docs(docker): stop advertising :focal tag (#12792)
PR: #12791
2022-03-15 19:39:08 +01:00
Max Schmitt 16c4ae0831
cherry-pick(1.20): docs: fix broken release-notes links (#12749)
PR: #12747
2022-03-14 13:29:25 -07:00
Andrey Lushnikov eab96de6e4
chore: mark v1.20.0 (#12600) 2022-03-14 11:12:29 -07:00
Andrey Lushnikov f9ea3d53bc
cherry-pick(#12596): add release notes for all other languages (#12745)
Drive-by:
- backport removal of toHaveScreenshot from release 1.20
- change webkit 10.15 announcement

This also cherry-picks a follow-up to .NET release notes:
- docs(release-notes): fix release notes for .net (#12743)
2022-03-14 11:12:15 -07:00
Andrey Lushnikov cf1a60bce8
chore: pull expect.toHaveScreenshot() from 1.20 release (#12702)
This patch pulls `expect.toHaveScreenshot()` from 1.20 release.
We'd like to experiment more.

After this patch, there is no 'toHaveScreenshot' mention in the
codebase.
2022-03-11 19:56:43 -08:00
Andrey Lushnikov 4897967780
cherry-pick(#12627): bring back maxDiffPixels in toMatchSnapshot (#12701)
This patch reverts 2 commits that removed the feature from the method:
- "fix: explicitly ignore maxDiffPixels in toMatchSnapshot (#12570)"
  commit b8af8458d6.
- "chore: remove `maxDiffPixels` from toMatchSnapshot (#12539)"
  commit a3dff45974.
2022-03-11 17:02:44 -08:00
Yury Semikhatsky 97bca5c431
cherry-pick(1.20): recent assertion docs changes (#12696) 2022-03-11 16:51:42 -08:00
Max Schmitt b728609592
cherry-pick(1.20): fix(test-runner): ESM compatibly on Windows (#12653)
PR: #12615
2022-03-10 19:51:44 +01:00
Yury Semikhatsky 2531c590bc
cherry-pick(1.20): add missing dependency on Ubuntu 18.04 (#12626)
d49843edaa

Fixes #12613
2022-03-09 11:52:53 -08:00
Yury Semikhatsky 503dc782b6
cherry-pick(1.20): add name for screenshot animations option (#12608) 2022-03-08 18:04:32 -08:00
Yury Semikhatsky 84f36df415
cherry-pick(1.20): update POM guide (#12598) (#12607) 2022-03-08 16:41:56 -08:00
63 changed files with 941 additions and 1331 deletions

View file

@ -56,9 +56,6 @@ jobs:
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [12] node-version: [12]
include:
- os: ubuntu-latest
node-version: 16
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -78,6 +75,26 @@ jobs:
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always() if: always()
test_test_runner_esm:
name: Test Runner ESM
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
- run: npm i -g npm@8.3
- run: npm ci
env:
DEBUG: pw:install
- run: npm run build
- run: npx playwright install --with-deps
- run: npm run ttest -- esm.spec.ts
test_html_report: test_html_report:
name: HTML Report name: HTML Report
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -328,7 +328,7 @@ Waits for the specific [`param: selector`] to either appear or disappear, depend
Selector to wait for. Selector to wait for.
### option: AndroidDevice.wait.state ### option: AndroidDevice.wait.state
- `state` <"gone"> - `state` <[AndroidDeviceState]<"gone">>
Optional state. Can be either: Optional state. Can be either:
* default - wait for element to be present. * default - wait for element to be present.

View file

@ -1,10 +1,10 @@
# class: APIRequest # class: APIRequest
* langs: js, java, python * langs: js, java, python
Exposes API that can be used for the Web API testing. Each Playwright browser context Exposes API that can be used for the Web API testing. This class is used for creating
has a APIRequestContext instance attached which shares cookies with the page context. [APIRequestContext] instance which in turn can be used for sending web requests. An instance
Its also possible to create a new APIRequestContext instance manually. For more information of this class can be obtained via [`property: Playwright.request`]. For more information
see [here](./class-apirequestcontext). see [APIRequestContext].
## async method: APIRequest.newContext ## async method: APIRequest.newContext
* langs: js, java, python * langs: js, java, python

View file

@ -2,9 +2,23 @@
* langs: js, java, python * langs: js, java, python
This API is used for the Web API testing. You can use it to trigger API endpoints, configure micro-services, prepare This API is used for the Web API testing. You can use it to trigger API endpoints, configure micro-services, prepare
environment or the service to your e2e test. When used on [Page] or a [BrowserContext], this API will automatically use environment or the service to your e2e test.
the cookies from the corresponding [BrowserContext]. This means that if you log in using this API, your e2e test
will be logged in and vice versa. Each Playwright browser context has associated with it [APIRequestContext] instance which shares cookie storage with
the browser context and can be accessed via [`property: BrowserContext.request`] or [`property: Page.request`].
It is also possible to create a new APIRequestContext instance manually by calling [`method: APIRequest.newContext`].
**Cookie management**
[APIRequestContext] retuned by [`property: BrowserContext.request`] and [`property: Page.request`] shares cookie
storage with the corresponding [BrowserContext]. Each API request will have `Cookie` header populated with the
values from the browser context. If the API response contains `Set-Cookie` header it will automatically update
[BrowserContext] cookies and requests made from the page will pick them up. This means that if you log in using
this API, your e2e test will be logged in and vice versa.
If you want API requests to not interfere with the browser cookies you shoud create a new [APIRequestContext] by
calling [`method: APIRequest.newContext`]. Such `APIRequestContext` object will have its own isolated cookie
storage.
```python async ```python async
import os import os

View file

@ -805,7 +805,6 @@ A permission or an array of permissions to grant. Permissions can be one of the
* `'midi'` * `'midi'`
* `'midi-sysex'` (system-exclusive midi) * `'midi-sysex'` (system-exclusive midi)
* `'notifications'` * `'notifications'`
* `'push'`
* `'camera'` * `'camera'`
* `'microphone'` * `'microphone'`
* `'background-sync'` * `'background-sync'`

View file

@ -998,36 +998,6 @@ Property value.
### option: LocatorAssertions.toHaveJSProperty.timeout = %%-js-assertions-timeout-%% ### option: LocatorAssertions.toHaveJSProperty.timeout = %%-js-assertions-timeout-%%
### option: LocatorAssertions.toHaveJSProperty.timeout = %%-csharp-java-python-assertions-timeout-%% ### option: LocatorAssertions.toHaveJSProperty.timeout = %%-csharp-java-python-assertions-timeout-%%
## async method: LocatorAssertions.toHaveScreenshot
* langs: js
Ensures that [Locator] resolves to a given screenshot. This function will re-take
screenshots until it matches with the saved expectation.
If there's no expectation yet, it will wait until two consecutive screenshots
yield the same result, and save the last one as an expectation.
```js
const locator = page.locator('button');
await expect(locator).toHaveScreenshot();
```
### option: LocatorAssertions.toHaveScreenshot.timeout = %%-js-assertions-timeout-%%
### option: LocatorAssertions.toHaveScreenshot.timeout = %%-csharp-java-python-assertions-timeout-%%
### option: LocatorAssertions.toHaveScreenshot.animations = %%-screenshot-option-animations-%%
### option: LocatorAssertions.toHaveScreenshot.omitBackground = %%-screenshot-option-omit-background-%%
### option: LocatorAssertions.toHaveScreenshot.mask = %%-screenshot-option-mask-%%
### option: LocatorAssertions.toHaveScreenshot.maxDiffPixels = %%-assertions-max-diff-pixels-%%
### option: LocatorAssertions.toHaveScreenshot.maxDiffPixelRatio = %%-assertions-max-diff-pixel-ratio-%%
### option: LocatorAssertions.toHaveScreenshot.threshold = %%-assertions-threshold-%%
## async method: LocatorAssertions.toHaveText ## async method: LocatorAssertions.toHaveText
* langs: * langs:
- alias-java: hasText - alias-java: hasText

View file

@ -28,7 +28,7 @@ Determines whether sink is interested in the logger with the given name and seve
logger name logger name
### param: Logger.isEnabled.severity ### param: Logger.isEnabled.severity
- `severity` <"verbose"|"info"|"warning"|"error"> - `severity` <[LogSeverity]<"verbose"|"info"|"warning"|"error">>
## method: Logger.log ## method: Logger.log
@ -38,7 +38,7 @@ logger name
logger name logger name
### param: Logger.log.severity ### param: Logger.log.severity
- `severity` <"verbose"|"info"|"warning"|"error"> - `severity` <[LogSeverity]<"verbose"|"info"|"warning"|"error">>
### param: Logger.log.message ### param: Logger.log.message
- `message` <[string]|[Error]> - `message` <[string]|[Error]>

View file

@ -2475,7 +2475,8 @@ last redirect.
* langs: js, java, python * langs: js, java, python
- type: <[APIRequestContext]> - type: <[APIRequestContext]>
API testing helper associated with this page. Requests made with this API will use page cookies. API testing helper associated with this page. This method returns the same instance as
[`property: BrowserContext.request`] on the page's context. See [`property: BrowserContext.request`] for more details.
## async method: Page.route ## async method: Page.route

View file

@ -114,38 +114,6 @@ Expected substring or RegExp.
### option: PageAssertions.NotToHaveURL.timeout = %%-js-assertions-timeout-%% ### option: PageAssertions.NotToHaveURL.timeout = %%-js-assertions-timeout-%%
### option: PageAssertions.NotToHaveURL.timeout = %%-csharp-java-python-assertions-timeout-%% ### option: PageAssertions.NotToHaveURL.timeout = %%-csharp-java-python-assertions-timeout-%%
## async method: PageAssertions.toHaveScreenshot
* langs: js
Ensures that the page resolves to a given screenshot. This function will re-take
screenshots until it matches with the saved expectation.
If there's no expectation yet, it will wait until two consecutive screenshots
yield the same result, and save the last one as an expectation.
```js
await expect(page).toHaveScreenshot();
```
### option: PageAssertions.toHaveScreenshot.timeout = %%-js-assertions-timeout-%%
### option: PageAssertions.toHaveScreenshot.timeout = %%-csharp-java-python-assertions-timeout-%%
### option: PageAssertions.toHaveScreenshot.animations = %%-screenshot-option-animations-%%
### option: PageAssertions.toHaveScreenshot.omitBackground = %%-screenshot-option-omit-background-%%
### option: PageAssertions.toHaveScreenshot.fullPage = %%-screenshot-option-full-page-%%
### option: PageAssertions.toHaveScreenshot.clip = %%-screenshot-option-clip-%%
### option: PageAssertions.toHaveScreenshot.mask = %%-screenshot-option-mask-%%
### option: PageAssertions.toHaveScreenshot.maxDiffPixels = %%-assertions-max-diff-pixels-%%
### option: PageAssertions.toHaveScreenshot.maxDiffPixelRatio = %%-assertions-max-diff-pixel-ratio-%%
### option: PageAssertions.toHaveScreenshot.threshold = %%-assertions-threshold-%%
## async method: PageAssertions.toHaveTitle ## async method: PageAssertions.toHaveTitle
* langs: * langs:
- alias-java: hasTitle - alias-java: hasTitle

View file

@ -2,8 +2,7 @@
* langs: js * langs: js
Playwright provides methods for comparing page and element screenshots with Playwright provides methods for comparing page and element screenshots with
expected values stored in files. See also [`method: PageAssertions.toHaveScreenshot`] and expected values stored in files.
[`LocatorAssertions.toHaveScreenshot`].
```js ```js
expect(screenshot).toMatchSnapshot('landing-page.png'); expect(screenshot).toMatchSnapshot('landing-page.png');
@ -34,4 +33,8 @@ Learn more about [visual comparisons](./test-snapshots.md).
Snapshot name. Snapshot name.
### option: ScreenshotAssertions.toMatchSnapshot.maxDiffPixels = %%-assertions-max-diff-pixels-%%
### option: ScreenshotAssertions.toMatchSnapshot.maxDiffPixelRatio = %%-assertions-max-diff-pixel-ratio-%%
### option: ScreenshotAssertions.toMatchSnapshot.threshold = %%-assertions-threshold-%% ### option: ScreenshotAssertions.toMatchSnapshot.threshold = %%-assertions-threshold-%%

View file

@ -902,7 +902,7 @@ Note that outer and inner locators must belong to the same frame. Inner locator
- %%-locator-option-has-%% - %%-locator-option-has-%%
## screenshot-option-animations ## screenshot-option-animations
- `animations` <"disabled"> - `animations` <[ScreenshotAnimations]<"disabled">>
When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on their duration: When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on their duration:
* finite animations are fast-forwarded to completion, so they'll fire `transitionend` event. * finite animations are fast-forwarded to completion, so they'll fire `transitionend` event.

View file

@ -13,23 +13,7 @@ This image is published on [Docker Hub].
### Pull the image ### Pull the image
```bash js Replace 1.20.0 with your Playwright version:
docker pull mcr.microsoft.com/playwright:focal
```
```bash python
docker pull mcr.microsoft.com/playwright/python:focal
```
```bash csharp
docker pull mcr.microsoft.com/playwright:focal
```
```bash java
docker pull mcr.microsoft.com/playwright/java:focal
```
or pinned to a specific Playwright version (recommended). Replace 1.20.0 with your Playwright version:
```bash js ```bash js
docker pull mcr.microsoft.com/playwright:v1.20.0-focal docker pull mcr.microsoft.com/playwright:v1.20.0-focal
@ -56,19 +40,19 @@ By default, the Docker image will use the `root` user to run the browsers. This
On trusted websites, you can avoid creating a separate user and use root for it since you trust the code which will run on the browsers. On trusted websites, you can avoid creating a separate user and use root for it since you trust the code which will run on the browsers.
```bash js ```bash js
docker run -it --rm --ipc=host mcr.microsoft.com/playwright:focal /bin/bash docker run -it --rm --ipc=host mcr.microsoft.com/playwright:v1.20.0-focal /bin/bash
``` ```
```bash python ```bash python
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/python:focal /bin/bash docker run -it --rm --ipc=host mcr.microsoft.com/playwright/python:v1.20.0-focal /bin/bash
``` ```
```bash csharp ```bash csharp
docker run -it --rm --ipc=host mcr.microsoft.com/playwright:focal /bin/bash docker run -it --rm --ipc=host mcr.microsoft.com/playwright:v1.20.0-focal /bin/bash
``` ```
```bash java ```bash java
docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:focal /bin/bash docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:v1.20.0-focal /bin/bash
``` ```
#### Crawling and scraping #### Crawling and scraping
@ -76,19 +60,19 @@ docker run -it --rm --ipc=host mcr.microsoft.com/playwright/java:focal /bin/bash
On untrusted websites, it's recommended to use a separate user for launching the browsers in combination with the seccomp profile. Inside the container or if you are using the Docker image as a base image you have to use `adduser` for it. On untrusted websites, it's recommended to use a separate user for launching the browsers in combination with the seccomp profile. Inside the container or if you are using the Docker image as a base image you have to use `adduser` for it.
```bash js ```bash js
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright:focal /bin/bash docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright:v1.20.0-focal /bin/bash
``` ```
```bash python ```bash python
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/python:focal /bin/bash docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/python:v1.20.0-focal /bin/bash
``` ```
```bash csharp ```bash csharp
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright:focal /bin/bash docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright:v1.20.0-focal /bin/bash
``` ```
```bash java ```bash java
docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/java:focal /bin/bash docker run -it --rm --ipc=host --user pwuser --security-opt seccomp=seccomp_profile.json mcr.microsoft.com/playwright/java:v1.20.0-focal /bin/bash
``` ```
[`seccomp_profile.json`](https://github.com/microsoft/playwright/blob/main/utils/docker/seccomp_profile.json) is needed to run Chromium with sandbox. This is a [default Docker seccomp profile](https://github.com/docker/engine/blob/d0d99b04cf6e00ed3fc27e81fc3d94e7eda70af3/profiles/seccomp/default.json) with extra user namespace cloning permissions: [`seccomp_profile.json`](https://github.com/microsoft/playwright/blob/main/utils/docker/seccomp_profile.json) is needed to run Chromium with sandbox. This is a [default Docker seccomp profile](https://github.com/docker/engine/blob/d0d99b04cf6e00ed3fc27e81fc3d94e7eda70af3/profiles/seccomp/default.json) with extra user namespace cloning permissions:
@ -127,7 +111,6 @@ Docker images are published automatically by GitHub Actions. We currently publis
following tags (`v1.20.0` in this case is an example:): following tags (`v1.20.0` in this case is an example:):
- `:next` - tip-of-tree image version based on Ubuntu 20.04 LTS (Focal Fossa). - `:next` - tip-of-tree image version based on Ubuntu 20.04 LTS (Focal Fossa).
- `:next-focal` - tip-of-tree image version based on Ubuntu 20.04 LTS (Focal Fossa). - `:next-focal` - tip-of-tree image version based on Ubuntu 20.04 LTS (Focal Fossa).
- `:focal` - last Playwright release docker image based on Ubuntu 20.04 LTS (Focal Fossa).
- `:v1.20.0` - Playwright v1.20.0 release docker image based on Ubuntu 20.04 LTS (Focal Fossa). - `:v1.20.0` - Playwright v1.20.0 release docker image based on Ubuntu 20.04 LTS (Focal Fossa).
- `:v1.20.0-focal` - Playwright v1.20.0 release docker image based on Ubuntu 20.04 LTS (Focal Fossa). - `:v1.20.0-focal` - Playwright v1.20.0 release docker image based on Ubuntu 20.04 LTS (Focal Fossa).
- `:sha-XXXXXXX` - docker image for every commit that changed - `:sha-XXXXXXX` - docker image for every commit that changed

View file

@ -5,6 +5,61 @@ title: "Release notes"
<!-- TOC --> <!-- TOC -->
## Version 1.20
### Web-First Assertions
Playwright for .NET 1.20 introduces [Web-First Assertions](./test-assertions).
Consider the following example:
```csharp
using System.Threading.Tasks;
using Microsoft.Playwright.NUnit;
using NUnit.Framework;
namespace Playwright.TestingHarnessTest.NUnit
{
public class ExampleTests : PageTest
{
[Test]
public async Task StatusBecomesSubmitted()
{
await Expect(Page.Locator(".status")).ToHaveTextAsync("Submitted");
}
}
}
```
Playwright will be re-testing the node with the selector `.status` until
fetched Node has the `"Submitted"` text. It will be re-fetching the node and
checking it over and over, until the condition is met or until the timeout is
reached. You can pass this timeout as an option.
Read more in [our documentation](./test-assertions).
### Other Updates
- New options for methods [`method: Page.screenshot`], [`method: Locator.screenshot`] and [`method: ElementHandle.screenshot`]:
* Option `ScreenshotAnimations.Disabled` rewinds all CSS animations and transitions to a consistent state
* Option `mask: Locator[]` masks given elements, overlaying them with pink `#FF00FF` boxes.
- [`method: Locator.highlight`] visually reveals element(s) for easier debugging.
### Announcements
- v1.20 is the last release to receive WebKit update for macOS 10.15 Catalina. Please update MacOS to keep using latest & greatest WebKit!
### Browser Versions
- Chromium 101.0.4921.0
- Mozilla Firefox 97.0.1
- WebKit 15.4
This version was also tested against the following stable channels:
- Google Chrome 99
- Microsoft Edge 99
## Version 1.19 ## Version 1.19
### Highlights ### Highlights

View file

@ -5,6 +5,32 @@ title: "Release notes"
<!-- TOC --> <!-- TOC -->
## Version 1.20
### Highlights
- New options for methods [`method: Page.screenshot`], [`method: Locator.screenshot`] and [`method: ElementHandle.screenshot`]:
* Option `ScreenshotAnimations.DISABLED` rewinds all CSS animations and transitions to a consistent state
* Option `mask: Locator[]` masks given elements, overlaying them with pink `#FF00FF` boxes.
- [Trace Viewer](./trace-viewer) now shows [API testing requests](./api-testing).
- [`method: Locator.highlight`] visually reveals element(s) for easier debugging.
### Announcements
- v1.20 is the last release to receive WebKit update for macOS 10.15 Catalina. Please update MacOS to keep using latest & greatest WebKit!
### Browser Versions
- Chromium 101.0.4921.0
- Mozilla Firefox 97.0.1
- WebKit 15.4
This version was also tested against the following stable channels:
- Google Chrome 99
- Microsoft Edge 99
## Version 1.19 ## Version 1.19
### Highlights ### Highlights
@ -54,7 +80,7 @@ Read more about it in our [API testing guide](./api-testing).
### Web-First Assertions ### Web-First Assertions
Playwright for Java 1.18 introduces [Web-First Assertions](./api/class-playwrightassertions). Playwright for Java 1.18 introduces [Web-First Assertions](./test-assertions).
Consider the following example: Consider the following example:
@ -78,7 +104,7 @@ fetched Node has the `"Submitted"` text. It will be re-fetching the node and
checking it over and over, until the condition is met or until the timeout is checking it over and over, until the condition is met or until the timeout is
reached. You can pass this timeout as an option. reached. You can pass this timeout as an option.
Read more in [our documentation](./api/class-playwrightassertions). Read more in [our documentation](./test-assertions).
### Locator Improvements ### Locator Improvements

View file

@ -7,28 +7,21 @@ title: "Release notes"
## Version 1.20 ## Version 1.20
### Visual Regression Testing ### Highlights
- New options for methods [`method: Page.screenshot`], [`method: Locator.screenshot`] and [`method: ElementHandle.screenshot`]: - New options for methods [`method: Page.screenshot`], [`method: Locator.screenshot`] and [`method: ElementHandle.screenshot`]:
* Option `animations: "disabled"` rewinds all CSS animations and transitions to a consistent state * Option `animations: "disabled"` rewinds all CSS animations and transitions to a consistent state
* Option `mask: Locator[]` masks given elements, overlaying them with pink `#FF00FF` boxes. * Option `mask: Locator[]` masks given elements, overlaying them with pink `#FF00FF` boxes.
- New web-first assertions for screenshots: [`method: PageAssertions.toHaveScreenshot`] and [`method: LocatorAssertions.toHaveScreenshot`]. These methods will re-take screenshot until it matches the saved expectation. When generating a new expectation, the method will re-take screenshots until 2 consecutive screenshots match. - `expect().toMatchSnapshot()` now supports anonymous snapshots: when snapshot name is missing, Playwright Test will generate one
automatically:
New methods support both named and anonymous (auto-named) expectations:
```js ```js
// Take a full-page screenshot with a named expectation `fullpage.png`. expect('Web is Awesome <3').toMatchSnapshot();
await expect(page).toHaveScreenshot('fullpage.png', { fullPage: true });
// Take a screenshot of an element with anonymous expectation.
await expect(page.locator('text=Booking')).toHaveScreenshot();
``` ```
- New `maxDiffPixels` and `maxDiffPixelRatio` options for fine-grained screenshot comparison using `expect().toMatchSnapshot()`:
Methods support all screenshot options from [`method: Page.screenshot`] and [`method: Locator.screenshot`].
These methods also support new `maxDiffPixels` and `maxDiffPixelRatio` options for fine-grained screenshot comparison:
```js ```js
await expect(page).toHaveScreenshot({ expect(await page.screenshot()).toMatchSnapshot({
fullPage: true, // take a full page screenshot fullPage: true, // take a full page screenshot
maxDiffPixels: 27, // allow no more than 27 different pixels. maxDiffPixels: 27, // allow no more than 27 different pixels.
}); });
@ -36,8 +29,6 @@ title: "Release notes"
It is most convenient to specify `maxDiffPixels` or `maxDiffPixelRatio` once in [`property: TestConfig.expect`]. It is most convenient to specify `maxDiffPixels` or `maxDiffPixelRatio` once in [`property: TestConfig.expect`].
### Other Updates
- Playwright Test now adds [`property: TestConfig.fullyParallel`] mode. By default, Playwright Test parallelizes between files. In fully parallel mode, tests inside a single file are also run in parallel. You can also use `--fully-parallel` command line flag. - Playwright Test now adds [`property: TestConfig.fullyParallel`] mode. By default, Playwright Test parallelizes between files. In fully parallel mode, tests inside a single file are also run in parallel. You can also use `--fully-parallel` command line flag.
```ts ```ts
@ -55,28 +46,20 @@ title: "Release notes"
projects: [ projects: [
{ {
name: 'smoke tests', name: 'smoke tests',
grep: '@smoke', grep: /@smoke/,
}, },
], ],
}; };
``` ```
- [Trace Viewer](./trace-viewer) now shows [API testing requests](./src/test-api-testing). - [Trace Viewer](./trace-viewer) now shows [API testing requests](./test-api-testing).
- `expect().toMatchSnapshot()` now supports anonymous snapshots: when snapshot name is missing, Playwright Test will generate one
automatically:
```js
expect('Web is Awesome <3').toMatchSnapshot();
```
- [`method: Locator.highlight`] visually reveals element(s) for easier debugging. - [`method: Locator.highlight`] visually reveals element(s) for easier debugging.
### Announcements ### Announcements
- We now ship a designated Python docker image `mcr.microsoft.com/playwright/python`. Please switch over to it if you use - We now ship a designated Python docker image `mcr.microsoft.com/playwright/python`. Please switch over to it if you use
Python. This is the last release that includes Python inside our javascript `mcr.microsoft.com/playwright` docker image. Python. This is the last release that includes Python inside our javascript `mcr.microsoft.com/playwright` docker image.
- v1.20 is the last release that ships WebKit for macOS 10.15 Catalina. All future versions will support WebKit for macOS 11 BigSur - v1.20 is the last release to receive WebKit update for macOS 10.15 Catalina. Please update MacOS to keep using latest & greatest WebKit!
and up.
### Browser Versions ### Browser Versions
@ -178,7 +161,7 @@ This version was also tested against the following stable channels:
### Locator Improvements ### Locator Improvements
- [`method: Locator.dragTo`] - [`method: Locator.dragTo`]
- [`expect(locator).toBeChecked({ checked })`](./api/class-playwrightassertions#locator-assertions-to-be-checked) - [`expect(locator).toBeChecked({ checked })`](./test-assertions#locator-assertions-to-be-checked)
- Each locator can now be optionally filtered by the text it contains: - Each locator can now be optionally filtered by the text it contains:
```js ```js
await page.locator('li', { hasText: 'my item' }).locator('button').click(); await page.locator('li', { hasText: 'my item' }).locator('button').click();
@ -188,7 +171,7 @@ This version was also tested against the following stable channels:
### Testing API improvements ### Testing API improvements
- [`expect(response).toBeOK()`](./api/class-playwrightassertions) - [`expect(response).toBeOK()`](./test-assertions)
- [`testInfo.attach()`](./api/class-testinfo#test-info-attach) - [`testInfo.attach()`](./api/class-testinfo#test-info-attach)
- [`test.info()`](./api/class-test#test-info) - [`test.info()`](./api/class-test#test-info)

View file

@ -5,6 +5,33 @@ title: "Release notes"
<!-- TOC --> <!-- TOC -->
## Version 1.20
### Highlights
- New options for methods [`method: Page.screenshot`], [`method: Locator.screenshot`] and [`method: ElementHandle.screenshot`]:
* Option `animations: "disabled"` rewinds all CSS animations and transitions to a consistent state
* Option `mask: Locator[]` masks given elements, overlaying them with pink `#FF00FF` boxes.
- [Trace Viewer](./trace-viewer) now shows [API testing requests](./api-testing).
- [`method: Locator.highlight`] visually reveals element(s) for easier debugging.
### Announcements
- We now ship a designated Python docker image `mcr.microsoft.com/playwright/python`. Please switch over to it if you use
Python. This is the last release that includes Python inside our javascript `mcr.microsoft.com/playwright` docker image.
- v1.20 is the last release to receive WebKit update for macOS 10.15 Catalina. Please update MacOS to keep using latest & greatest WebKit!
### Browser Versions
- Chromium 101.0.4921.0
- Mozilla Firefox 97.0.1
- WebKit 15.4
This version was also tested against the following stable channels:
- Google Chrome 99
- Microsoft Edge 99
## Version 1.19 ## Version 1.19
### Highlights ### Highlights
@ -64,7 +91,7 @@ Read more in [our documentation](./api/class-apirequestcontext).
### Web-First Assertions ### Web-First Assertions
Playwright for Python 1.18 introduces [Web-First Assertions](./api/class-playwrightassertions). Playwright for Python 1.18 introduces [Web-First Assertions](./test-assertions).
Consider the following example: Consider the following example:
@ -91,7 +118,7 @@ fetched Node has the `"Submitted"` text. It will be re-fetching the node and
checking it over and over, until the condition is met or until the timeout is checking it over and over, until the condition is met or until the timeout is
reached. You can pass this timeout as an option. reached. You can pass this timeout as an option.
Read more in [our documentation](./api/class-playwrightassertions). Read more in [our documentation](./test-assertions).
### Locator Improvements ### Locator Improvements

View file

@ -387,3 +387,73 @@ await requestContext.storageState({ path: 'state.json' });
// Create a new context with the saved storage state. // Create a new context with the saved storage state.
const context = await browser.newContext({ storageState: 'state.json' }); const context = await browser.newContext({ storageState: 'state.json' });
``` ```
## Context request vs global request
There are two types of [APIRequestContext]:
* associated with a [BrowserContext]
* isolated instance, created via [`method: APIRequest.newContext`]
The main difference is that [APIRequestConxtext] accessible via [`property: BrowserContext.request`] and
[`property: Page.request`] will populate request's `Cookie` header from the browser context and will
automatically update browser cookies if [APIResponse] has `Set-Cookie` header:
```js
test('context request will share cookie storage with its browser context', async ({ page, context }) => {
await context.route('https://www.github.com/', async (route) => {
// Send an API request that shares cookie storage with the browser context.
const response = await context.request.fetch(route.request());
const responseHeaders = response.headers();
// The response will have 'Set-Cookie' header.
const responseCookies = new Map(responseHeaders['set-cookie'].split('\n').map(c => c.split(';', 2)[0].split('=')));
// The response will have 3 cookies in 'Set-Cookie' header.
expect(responseCookies.size).toBe(3);
const contextCookies = await context.cookies();
// The browser context will already contain all the cookies from the API response.
expect(new Map(contextCookies.map(({name, value}) => [name, value]))).toEqual(responseCookies);
route.fulfill({
response,
headers: {...responseHeaders, foo: 'bar'},
});
});
await page.goto('https://www.github.com/');
});
```
If you don't want [APIRequestContext] to use and update cookies from the browser context, you can manually
create a new instance of [APIRequestContext] which will have its own isolated cookies:
```js
test('global context request has isolated cookie storage', async ({ page, context, browser, playwright }) => {
// Create a new instance of APIRequestContext with isolated cookie storage.
const request = await playwright.request.newContext();
await context.route('https://www.github.com/', async (route) => {
const response = await request.fetch(route.request());
const responseHeaders = response.headers();
const responseCookies = new Map(responseHeaders['set-cookie'].split('\n').map(c => c.split(';', 2)[0].split('=')));
// The response will have 3 cookies in 'Set-Cookie' header.
expect(responseCookies.size).toBe(3);
const contextCookies = await context.cookies();
// The browser context will not have any cookies from the isolated API request.
expect(contextCookies.length).toBe(0);
// Manually export cookie storage.
const storageState = await request.storageState();
// Create a new context and initialize it with the cookies from the global request.
const browserContext2 = await browser.newContext({ storageState });
const contextCookies2 = await browserContext2.cookies();
// The new browser context will already contain all the cookies from the API response.
expect(new Map(contextCookies2.map(({name, value}) => [name, value]))).toEqual(responseCookies);
route.fulfill({
response,
headers: {...responseHeaders, foo: 'bar'},
});
});
await page.goto('https://www.github.com/');
await request.dispose();
});
```

View file

@ -288,7 +288,7 @@ test('runs second', async ({ page }) => {});
``` ```
### option: Test.describe.configure.mode ### option: Test.describe.configure.mode
- `mode` <"parallel"|"serial"> - `mode` <[TestMode]<"parallel"|"serial">>

View file

@ -36,12 +36,10 @@ export default config;
## property: TestConfig.expect ## property: TestConfig.expect
- type: <[Object]> - type: <[Object]>
- `timeout` <[int]> Default timeout for async expect matchers in milliseconds, defaults to 5000ms. - `timeout` <[int]> Default timeout for async expect matchers in milliseconds, defaults to 5000ms.
- `toHaveScreenshot` <[Object]> - `toMatchSnapshot` <[Object]>
- `threshold` <[float]> an acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the same pixel in compared images, between zero (strict) and one (lax). Defaults to `0.2`. - `threshold` <[float]> an acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the same pixel in compared images, between zero (strict) and one (lax). Defaults to `0.2`.
- `maxDiffPixels` <[int]> an acceptable amount of pixels that could be different, unset by default. - `maxDiffPixels` <[int]> an acceptable amount of pixels that could be different, unset by default.
- `maxDiffPixelRatio` <[float]> an acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1` , unset by default. - `maxDiffPixelRatio` <[float]> an acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1` , unset by default.
- `toMatchSnapshot` <[Object]>
- `threshold` <[float]> an acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the same pixel in compared images, between zero (strict) and one (lax). Defaults to `0.2`.
Configuration for the `expect` assertion library. Learn more about [various timeouts](./test-timeouts.md). Configuration for the `expect` assertion library. Learn more about [various timeouts](./test-timeouts.md).
@ -53,7 +51,7 @@ Configuration for the `expect` assertion library. Learn more about [various time
const config = { const config = {
expect: { expect: {
timeout: 10000, timeout: 10000,
toHaveScreenshot: { toMatchSnapshot: {
maxDiffPixels: 10, maxDiffPixels: 10,
}, },
}, },
@ -69,7 +67,7 @@ import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
expect: { expect: {
timeout: 10000, timeout: 10000,
toHaveScreenshot: { toMatchSnapshot: {
maxDiffPixels: 10, maxDiffPixels: 10,
}, },
}, },
@ -301,7 +299,7 @@ test('example test', async ({}, testInfo) => {
## property: TestConfig.snapshotDir ## property: TestConfig.snapshotDir
- type: <[string]> - type: <[string]>
The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot` and `toHaveScreenshot`. Defaults to [`property: TestConfig.testDir`]. The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot`. Defaults to [`property: TestConfig.testDir`].
The directory for each test can be accessed by [`property: TestInfo.snapshotDir`] and [`method: TestInfo.snapshotPath`]. The directory for each test can be accessed by [`property: TestInfo.snapshotDir`] and [`method: TestInfo.snapshotPath`].

View file

@ -382,7 +382,7 @@ The name of the snapshot or the path segments to define the snapshot file path.
## property: TestInfo.snapshotSuffix ## property: TestInfo.snapshotSuffix
- type: <[string]> - type: <[string]>
Suffix used to differentiate snapshots between multiple test configurations. For example, if snapshots depend on the platform, you can set `testInfo.snapshotSuffix` equal to `process.platform`. In this case both `expect(value).toMatchSnapshot(snapshotName)` and `expect(page).toHaveScreenshot(snapshotName)` will use different snapshots depending on the platform. Learn more about [snapshots](./test-snapshots.md). Suffix used to differentiate snapshots between multiple test configurations. For example, if snapshots depend on the platform, you can set `testInfo.snapshotSuffix` equal to `process.platform`. In this case both `expect(value).toMatchSnapshot(snapshotName)` will use different snapshots depending on the platform. Learn more about [snapshots](./test-snapshots.md).
## property: TestInfo.status ## property: TestInfo.status
- type: <[void]|[TestStatus]<"passed"|"failed"|"timedOut"|"skipped">> - type: <[void]|[TestStatus]<"passed"|"failed"|"timedOut"|"skipped">>

View file

@ -107,12 +107,10 @@ export default config;
## property: TestProject.expect ## property: TestProject.expect
- type: <[Object]> - type: <[Object]>
- `timeout` <[int]> Default timeout for async expect matchers in milliseconds, defaults to 5000ms. - `timeout` <[int]> Default timeout for async expect matchers in milliseconds, defaults to 5000ms.
- `toHaveScreenshot` <[Object]> - `toMatchSnapshot` <[Object]>
- `threshold` <[float]> an acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the same pixel in compared images, between zero (strict) and one (lax). Defaults to `0.2`. - `threshold` <[float]> an acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the same pixel in compared images, between zero (strict) and one (lax). Defaults to `0.2`.
- `maxDiffPixels` <[int]> an acceptable amount of pixels that could be different, unset by default. - `maxDiffPixels` <[int]> an acceptable amount of pixels that could be different, unset by default.
- `maxDiffPixelRatio` <[float]> an acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1` , unset by default. - `maxDiffPixelRatio` <[float]> an acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1` , unset by default.
- `toMatchSnapshot` <[Object]>
- `threshold` <[float]> an acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the same pixel in compared images, between zero (strict) and one (lax). Defaults to `0.2`.
Configuration for the `expect` assertion library. Configuration for the `expect` assertion library.
@ -153,7 +151,7 @@ Project name is visible in the report and during test execution.
## property: TestProject.snapshotDir ## property: TestProject.snapshotDir
- type: <[string]> - type: <[string]>
The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot` and `toHaveScreenshot`. Defaults to [`property: TestProject.testDir`]. The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot`. Defaults to [`property: TestProject.testDir`].
The directory for each test can be accessed by [`property: TestInfo.snapshotDir`] and [`method: TestInfo.snapshotPath`]. The directory for each test can be accessed by [`property: TestInfo.snapshotDir`] and [`method: TestInfo.snapshotPath`].

View file

@ -18,8 +18,9 @@ exports.PlaywrightDevPage = class PlaywrightDevPage {
*/ */
constructor(page) { constructor(page) {
this.page = page; this.page = page;
this.getStartedLink = page.locator('text=Get started'); this.getStartedLink = page.locator('a', { hasText: 'Get started' });
this.coreConceptsLink = page.locator('text=Core concepts'); this.gettingStartedHeader = page.locator('h1', { hasText: 'Getting started' });
this.pomLink = page.locator('li', { hasText: 'Playwright Test' }).locator('a', { hasText: 'Page Object Model' });
this.tocList = page.locator('article ul > li > a'); this.tocList = page.locator('article ul > li > a');
} }
@ -29,14 +30,12 @@ exports.PlaywrightDevPage = class PlaywrightDevPage {
async getStarted() { async getStarted() {
await this.getStartedLink.first().click(); await this.getStartedLink.first().click();
await expect(this.coreConceptsLink).toBeVisible(); await expect(this.gettingStartedHeader).toBeVisible();
} }
async coreConcepts() { async pageObjectModel() {
await this.getStarted(); await this.getStarted();
await this.page.click('text=Guides'); await this.pomLink.click();
await this.coreConceptsLink.click();
await expect(this.page.locator('h1').locator("text=Core concepts")).toBeVisible();
} }
} }
``` ```
@ -48,13 +47,15 @@ import { expect, Locator, Page } from '@playwright/test';
export class PlaywrightDevPage { export class PlaywrightDevPage {
readonly page: Page; readonly page: Page;
readonly getStartedLink: Locator; readonly getStartedLink: Locator;
readonly coreConceptsLink: Locator; readonly gettingStartedHeader: Locator;
readonly pomLink: Locator;
readonly tocList: Locator; readonly tocList: Locator;
constructor(page: Page) { constructor(page: Page) {
this.page = page; this.page = page;
this.getStartedLink = page.locator('text=Get started'); this.getStartedLink = page.locator('a', { hasText: 'Get started' });
this.coreConceptsLink = page.locator('text=Core concepts'); this.gettingStartedHeader = page.locator('h1', { hasText: 'Getting started' });
this.pomLink = page.locator('li', { hasText: 'Playwright Test' }).locator('a', { hasText: 'Page Object Model' });
this.tocList = page.locator('article ul > li > a'); this.tocList = page.locator('article ul > li > a');
} }
@ -64,14 +65,12 @@ export class PlaywrightDevPage {
async getStarted() { async getStarted() {
await this.getStartedLink.first().click(); await this.getStartedLink.first().click();
await expect(this.coreConceptsLink).toBeVisible(); await expect(this.gettingStartedHeader).toBeVisible();
} }
async coreConcepts() { async pageObjectModel() {
await this.getStarted(); await this.getStarted();
await this.page.click('text=Guides'); await this.pomLink.click();
await this.coreConceptsLink.click();
await expect(this.page.locator('h1').locator("text=Core concepts")).toBeVisible();
} }
} }
``` ```
@ -83,27 +82,28 @@ Now we can use the `PlaywrightDevPage` class in our tests.
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { PlaywrightDevPage } = require('./playwright-dev-page'); const { PlaywrightDevPage } = require('./playwright-dev-page');
test('Get Started table of contents', async ({ page }) => { test('getting started should contain table of contents', async ({ page }) => {
const playwrightDev = new PlaywrightDevPage(page); const playwrightDev = new PlaywrightDevPage(page);
await playwrightDev.goto(); await playwrightDev.goto();
await playwrightDev.getStarted(); await playwrightDev.getStarted();
await expect(playwrightDev.tocList).toHaveText([ await expect(playwrightDev.tocList).toHaveText([
'Installation', 'Installation',
'First test', 'First test',
'Configuration file',
'Writing assertions', 'Writing assertions',
'Using test fixtures', 'Using test fixtures',
'Using test hooks', 'Using test hooks',
'Learning the command line', 'Command line',
'Creating a configuration file', 'Configure NPM scripts',
'Release notes', 'Release notes'
]); ]);
}); });
test('Core Concepts table of contents', async ({ page }) => { test('should show Page Object Model article', async ({ page }) => {
const playwrightDev = new PlaywrightDevPage(page); const playwrightDev = new PlaywrightDevPage(page);
await playwrightDev.goto(); await playwrightDev.goto();
await playwrightDev.coreConcepts(); await playwrightDev.pageObjectModel();
await expect(playwrightDev.tocList.first()).toHaveText('Browser'); await expect(page.locator('article')).toContainText('Page Object Model is a common pattern');
}); });
``` ```
@ -112,26 +112,27 @@ test('Core Concepts table of contents', async ({ page }) => {
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { PlaywrightDevPage } from './playwright-dev-page'; import { PlaywrightDevPage } from './playwright-dev-page';
test('Get Started table of contents', async ({ page }) => { test('getting started should contain table of contents', async ({ page }) => {
const playwrightDev = new PlaywrightDevPage(page); const playwrightDev = new PlaywrightDevPage(page);
await playwrightDev.goto(); await playwrightDev.goto();
await playwrightDev.getStarted(); await playwrightDev.getStarted();
await expect(playwrightDev.tocList).toHaveText([ await expect(playwrightDev.tocList).toHaveText([
'Installation', 'Installation',
'First test', 'First test',
'Configuration file',
'Writing assertions', 'Writing assertions',
'Using test fixtures', 'Using test fixtures',
'Using test hooks', 'Using test hooks',
'Learning the command line', 'Command line',
'Creating a configuration file', 'Configure NPM scripts',
'Release notes', 'Release notes'
]); ]);
}); });
test('Core Concepts table of contents', async ({ page }) => { test('should show Page Object Model article', async ({ page }) => {
const playwrightDev = new PlaywrightDevPage(page); const playwrightDev = new PlaywrightDevPage(page);
await playwrightDev.goto(); await playwrightDev.goto();
await playwrightDev.coreConcepts(); await playwrightDev.pageObjectModel();
await expect(playwrightDev.tocList.first()).toHaveText('Browser'); await expect(page.locator('article')).toContainText('Page Object Model is a common pattern');
}); });
``` ```

View file

@ -3,7 +3,7 @@ id: test-snapshots
title: "Visual comparisons" title: "Visual comparisons"
--- ---
Playwright Test includes the ability to produce and visually compare screenshots using `await expect(pageOrLocator).toHaveScreenshot()`. On first execution, Playwright test will generate reference screenshots. Subsequent runs will compare against the reference. Playwright Test includes the ability to produce and visually compare screenshots using `expect().toMatchSnapshot()`. On first execution, Playwright test will generate reference screenshots. Subsequent runs will compare against the reference.
```js js-flavor=js ```js js-flavor=js
// example.spec.js // example.spec.js
@ -11,7 +11,7 @@ const { test, expect } = require('@playwright/test');
test('example test', async ({ page }) => { test('example test', async ({ page }) => {
await page.goto('https://playwright.dev'); await page.goto('https://playwright.dev');
await expect(page).toHaveScreenshot(); expect(await page.screenshot()).toMatchSnapshot();
}); });
``` ```
@ -21,7 +21,7 @@ import { test, expect } from '@playwright/test';
test('example test', async ({ page }) => { test('example test', async ({ page }) => {
await page.goto('https://playwright.dev'); await page.goto('https://playwright.dev');
await expect(page).toHaveScreenshot(); expect(await page.screenshot()).toMatchSnapshot();
}); });
``` ```
@ -45,10 +45,10 @@ drwxr-xr-x 3 user group 96 Jun 4 11:46 example.spec.ts-snapshots
The snapshot name `example-test-1-chromium-darwin.png` consists of a few parts: The snapshot name `example-test-1-chromium-darwin.png` consists of a few parts:
- `example-test-1.png` - an auto-generated name of the snapshot. Alternatively you can specify snapshot name as the first argument of the `toMatchSnapshot()` method: - `example-test-1.png` - an auto-generated name of the snapshot. Alternatively you can specify snapshot name as the first argument of the `toMatchSnapshot()` method:
```js js-flavor=js ```js js-flavor=js
await expect(page).toHaveScreenshot('landing.png'); expect(await page.screenshot()).toMatchSnapshot('landing.png');
``` ```
```js js-flavor=ts ```js js-flavor=ts
await expect(page).toHaveScreenshot('landing.png'); expect(await page.screenshot()).toMatchSnapshot('landing.png');
``` ```
- `chromium-darwin` - the browser name and the platform. Screenshots differ between browsers and platforms due to different rendering, fonts and more, so you will need different snapshots for them. If you use multiple projects in your [configuration file](./test-configuration.md), project name will be used instead of `chromium`. - `chromium-darwin` - the browser name and the platform. Screenshots differ between browsers and platforms due to different rendering, fonts and more, so you will need different snapshots for them. If you use multiple projects in your [configuration file](./test-configuration.md), project name will be used instead of `chromium`.
@ -67,10 +67,10 @@ Sometimes you need to update the reference screenshot, for example when the page
npx playwright test --update-snapshots npx playwright test --update-snapshots
``` ```
> Note that `snapshotName` also accepts an array of path segments to the snapshot file such as `await expect(page).toHaveScreenshot(['relative', 'path', 'to', 'snapshot.png'])`. > Note that `snapshotName` also accepts an array of path segments to the snapshot file such as `expect().toMatchSnapshot(['relative', 'path', 'to', 'snapshot.png'])`.
> However, this path must stay within the snapshots directory for each test file (i.e. `a.spec.js-snapshots`), otherwise it will throw. > However, this path must stay within the snapshots directory for each test file (i.e. `a.spec.js-snapshots`), otherwise it will throw.
Playwright Test uses the [pixelmatch](https://github.com/mapbox/pixelmatch) library. You can [pass various options](./test-assertions#expectpageorlocatortohavescreenshot-options) to modify its behavior: You can pass various options to modify image comparison behavior - see [`method: ScreenshotAssertions.toMatchSnapshot`]:
```js js-flavor=js ```js js-flavor=js
// example.spec.js // example.spec.js
@ -78,7 +78,7 @@ const { test, expect } = require('@playwright/test');
test('example test', async ({ page }) => { test('example test', async ({ page }) => {
await page.goto('https://playwright.dev'); await page.goto('https://playwright.dev');
await expect(page).toHaveScreenshot({ maxDiffPixels: 100 }); expect(await page.screenshot()).toMatchSnapshot({ maxDiffPixels: 100 });
}); });
``` ```
@ -88,7 +88,7 @@ import { test, expect } from '@playwright/test';
test('example test', async ({ page }) => { test('example test', async ({ page }) => {
await page.goto('https://playwright.dev'); await page.goto('https://playwright.dev');
await expect(page).toHaveScreenshot({ maxDiffPixels: 100 }); expect(await page.screenshot()).toMatchSnapshot({ maxDiffPixels: 100 });
}); });
``` ```
@ -97,7 +97,7 @@ If you'd like to share the default value among all the tests in the project, you
```js js-flavor=js ```js js-flavor=js
module.exports = { module.exports = {
expect: { expect: {
toHaveScreenshot: { maxDiffPixels: 100 }, toMatchSnapshot: { maxDiffPixels: 100 },
}, },
}; };
``` ```
@ -106,7 +106,7 @@ module.exports = {
import { PlaywrightTestConfig } from '@playwright/test'; import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
expect: { expect: {
toHaveScreenshot: { maxDiffPixels: 100 }, toMatchSnapshot: { maxDiffPixels: 100 },
}, },
}; };
export default config; export default config;

View file

@ -4,7 +4,7 @@ title: "Verification"
--- ---
:::caution :::caution
We recommend [Web-First Assertions](./api/class-playwrightassertions) that automatically retry until the expected condition is met instead. This helps reducing the flakiness of the tests. We recommend [Web-First Assertions](./test-assertions) that automatically retry until the expected condition is met instead. This helps reducing the flakiness of the tests.
::: :::
<!-- TOC --> <!-- TOC -->

64
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.20.0-next", "version": "1.20.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.20.0-next", "version": "1.20.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@ -4856,12 +4856,9 @@
"optional": true "optional": true
}, },
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.0", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"dependencies": {
"minimist": "^1.2.5"
},
"bin": { "bin": {
"json5": "lib/cli.js" "json5": "lib/cli.js"
}, },
@ -5167,7 +5164,8 @@
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
}, },
"node_modules/mkdirp": { "node_modules/mkdirp": {
"version": "0.5.5", "version": "0.5.5",
@ -7299,11 +7297,11 @@
}, },
"packages/html-reporter": {}, "packages/html-reporter": {},
"packages/playwright": { "packages/playwright": {
"version": "1.20.0-next", "version": "1.20.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.20.0-next" "playwright-core": "1.20.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7313,11 +7311,11 @@
} }
}, },
"packages/playwright-chromium": { "packages/playwright-chromium": {
"version": "1.20.0-next", "version": "1.20.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.20.0-next" "playwright-core": "1.20.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7327,7 +7325,7 @@
} }
}, },
"packages/playwright-core": { "packages/playwright-core": {
"version": "1.20.0-next", "version": "1.20.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"colors": "1.4.0", "colors": "1.4.0",
@ -7365,11 +7363,11 @@
} }
}, },
"packages/playwright-firefox": { "packages/playwright-firefox": {
"version": "1.20.0-next", "version": "1.20.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.20.0-next" "playwright-core": "1.20.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7380,7 +7378,7 @@
}, },
"packages/playwright-test": { "packages/playwright-test": {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.20.0-next", "version": "1.20.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@babel/code-frame": "7.16.7", "@babel/code-frame": "7.16.7",
@ -7406,13 +7404,13 @@
"debug": "4.3.3", "debug": "4.3.3",
"expect": "27.2.5", "expect": "27.2.5",
"jest-matcher-utils": "27.2.5", "jest-matcher-utils": "27.2.5",
"json5": "2.2.0", "json5": "2.2.1",
"mime": "3.0.0", "mime": "3.0.0",
"minimatch": "3.0.4", "minimatch": "3.0.4",
"ms": "2.1.3", "ms": "2.1.3",
"open": "8.4.0", "open": "8.4.0",
"pirates": "4.0.4", "pirates": "4.0.4",
"playwright-core": "1.20.0-next", "playwright-core": "1.20.2",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"source-map-support": "0.4.18", "source-map-support": "0.4.18",
"stack-utils": "2.0.5", "stack-utils": "2.0.5",
@ -7455,11 +7453,11 @@
} }
}, },
"packages/playwright-webkit": { "packages/playwright-webkit": {
"version": "1.20.0-next", "version": "1.20.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.20.0-next" "playwright-core": "1.20.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -8221,13 +8219,13 @@
"debug": "4.3.3", "debug": "4.3.3",
"expect": "27.2.5", "expect": "27.2.5",
"jest-matcher-utils": "27.2.5", "jest-matcher-utils": "27.2.5",
"json5": "2.2.0", "json5": "2.2.1",
"mime": "3.0.0", "mime": "3.0.0",
"minimatch": "3.0.4", "minimatch": "3.0.4",
"ms": "2.1.3", "ms": "2.1.3",
"open": "8.4.0", "open": "8.4.0",
"pirates": "4.0.4", "pirates": "4.0.4",
"playwright-core": "1.20.0-next", "playwright-core": "1.20.2",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"source-map-support": "0.4.18", "source-map-support": "0.4.18",
"stack-utils": "2.0.5", "stack-utils": "2.0.5",
@ -11132,12 +11130,9 @@
"optional": true "optional": true
}, },
"json5": { "json5": {
"version": "2.2.0", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA=="
"requires": {
"minimist": "^1.2.5"
}
}, },
"jsonfile": { "jsonfile": {
"version": "4.0.0", "version": "4.0.0",
@ -11374,7 +11369,8 @@
"minimist": { "minimist": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.5", "version": "0.5.5",
@ -11744,13 +11740,13 @@
"playwright": { "playwright": {
"version": "file:packages/playwright", "version": "file:packages/playwright",
"requires": { "requires": {
"playwright-core": "1.20.0-next" "playwright-core": "1.20.2"
} }
}, },
"playwright-chromium": { "playwright-chromium": {
"version": "file:packages/playwright-chromium", "version": "file:packages/playwright-chromium",
"requires": { "requires": {
"playwright-core": "1.20.0-next" "playwright-core": "1.20.2"
} }
}, },
"playwright-core": { "playwright-core": {
@ -11786,13 +11782,13 @@
"playwright-firefox": { "playwright-firefox": {
"version": "file:packages/playwright-firefox", "version": "file:packages/playwright-firefox",
"requires": { "requires": {
"playwright-core": "1.20.0-next" "playwright-core": "1.20.2"
} }
}, },
"playwright-webkit": { "playwright-webkit": {
"version": "file:packages/playwright-webkit", "version": "file:packages/playwright-webkit",
"requires": { "requires": {
"playwright-core": "1.20.0-next" "playwright-core": "1.20.2"
} }
}, },
"pngjs": { "pngjs": {

View file

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

View file

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

View file

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

View file

@ -316,7 +316,7 @@ const DEFAULT_ARGS = [
'--disable-default-apps', '--disable-default-apps',
'--disable-dev-shm-usage', '--disable-dev-shm-usage',
'--disable-extensions', '--disable-extensions',
'--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,AcceptCHFrame,AutoExpandDetailsElement', '--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater',
'--allow-pre-commit-input', '--allow-pre-commit-input',
'--disable-hang-monitor', '--disable-hang-monitor',
'--disable-ipc-flooding-protection', '--disable-ipc-flooding-protection',

View file

@ -441,7 +441,7 @@ export class Frame extends SdkObject {
private _setContentCounter = 0; private _setContentCounter = 0;
readonly _detachedPromise: Promise<void>; readonly _detachedPromise: Promise<void>;
private _detachedCallback = () => {}; private _detachedCallback = () => {};
private _nonStallingEvaluations = new Set<(error: Error) => void>(); private _raceAgainstEvaluationStallingEventsPromises = new Set<ManualPromise<any>>();
constructor(page: Page, id: string, parentFrame: Frame | null) { constructor(page: Page, id: string, parentFrame: Frame | null) {
super(page, 'frame'); super(page, 'frame');
@ -500,53 +500,47 @@ export class Frame extends SdkObject {
} }
_invalidateNonStallingEvaluations(message: string) { _invalidateNonStallingEvaluations(message: string) {
if (!this._nonStallingEvaluations) if (!this._raceAgainstEvaluationStallingEventsPromises.size)
return; return;
const error = new Error(message); const error = new Error(message);
for (const callback of this._nonStallingEvaluations) for (const promise of this._raceAgainstEvaluationStallingEventsPromises)
callback(error); promise.reject(error);
} }
async nonStallingRawEvaluateInExistingMainContext(expression: string): Promise<any> { async raceAgainstEvaluationStallingEvents<T>(cb: () => Promise<T>): Promise<T> {
if (this._pendingDocument) if (this._pendingDocument)
throw new Error('Frame is currently attempting a navigation'); throw new Error('Frame is currently attempting a navigation');
if (this._page._frameManager._openedDialogs.size) if (this._page._frameManager._openedDialogs.size)
throw new Error('Open JavaScript dialog prevents evaluation'); throw new Error('Open JavaScript dialog prevents evaluation');
const context = this._existingMainContext();
if (!context)
throw new Error('Frame does not yet have a main execution context');
let callback = () => {}; const promise = new ManualPromise<T>();
const frameInvalidated = new Promise<void>((f, r) => callback = r); this._raceAgainstEvaluationStallingEventsPromises.add(promise);
this._nonStallingEvaluations.add(callback);
try { try {
return await Promise.race([ return await Promise.race([
context.rawEvaluateJSON(expression), cb(),
frameInvalidated promise
]); ]);
} finally { } finally {
this._nonStallingEvaluations.delete(callback); this._raceAgainstEvaluationStallingEventsPromises.delete(promise);
} }
} }
async nonStallingEvaluateInExistingContext(expression: string, isFunction: boolean|undefined, world: types.World): Promise<any> { nonStallingRawEvaluateInExistingMainContext(expression: string): Promise<any> {
if (this._pendingDocument) return this.raceAgainstEvaluationStallingEvents(() => {
throw new Error('Frame is currently attempting a navigation'); const context = this._existingMainContext();
const context = this._contextData.get(world)?.context; if (!context)
if (!context) throw new Error('Frame does not yet have a main execution context');
throw new Error('Frame does not yet have the execution context'); return context.rawEvaluateJSON(expression);
});
}
let callback = () => {}; nonStallingEvaluateInExistingContext(expression: string, isFunction: boolean|undefined, world: types.World): Promise<any> {
const frameInvalidated = new Promise<void>((f, r) => callback = r); return this.raceAgainstEvaluationStallingEvents(() => {
this._nonStallingEvaluations.add(callback); const context = this._contextData.get(world)?.context;
try { if (!context)
return await Promise.race([ throw new Error('Frame does not yet have the execution context');
context.evaluateExpression(expression, isFunction), return context.evaluateExpression(expression, isFunction);
frameInvalidated });
]);
} finally {
this._nonStallingEvaluations.delete(callback);
}
} }
private _recalculateLifecycle() { private _recalculateLifecycle() {
@ -1168,10 +1162,12 @@ export class Frame extends SdkObject {
} }
async hideHighlight() { async hideHighlight() {
const context = await this._utilityContext(); return this.raceAgainstEvaluationStallingEvents(async () => {
const injectedScript = await context.injectedScript(); const context = await this._utilityContext();
return await injectedScript.evaluate(injected => { const injectedScript = await context.injectedScript();
return injected.hideHighlight(); return await injectedScript.evaluate(injected => {
return injected.hideHighlight();
});
}); });
} }

View file

@ -804,8 +804,11 @@ export class InjectedScript {
while (container) { while (container) {
// elementFromPoint works incorrectly in Chromium (http://crbug.com/1188919), // elementFromPoint works incorrectly in Chromium (http://crbug.com/1188919),
// so we use elementsFromPoint instead. // so we use elementsFromPoint instead.
const elements = (container as Document).elementsFromPoint(x, y); const elements: Element[] = container.elementsFromPoint(x, y);
const innerElement = elements[0] as Element | undefined; let innerElement = elements[0] as Element | undefined;
// Workaround https://bugs.chromium.org/p/chromium/issues/detail?id=1307458.
if (elements[0] && elements[1] && elements[0].contains(elements[1]) && container.elementFromPoint(x, y) === elements[1])
innerElement = elements[1];
if (!innerElement || element === innerElement) if (!innerElement || element === innerElement)
break; break;
element = innerElement; element = innerElement;

View file

@ -31,7 +31,7 @@ import { Progress, ProgressController } from './progress';
import { assert, isError } from '../utils/utils'; import { assert, isError } from '../utils/utils';
import { ManualPromise } from '../utils/async'; import { ManualPromise } from '../utils/async';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import { mimeTypeToComparator, ImageComparatorOptions, ComparatorResult } from '../utils/comparators'; import { getComparator, ImageComparatorOptions, ComparatorResult } from '../utils/comparators';
import { SelectorInfo, Selectors } from './selectors'; import { SelectorInfo, Selectors } from './selectors';
import { CallMetadata, SdkObject } from './instrumentation'; import { CallMetadata, SdkObject } from './instrumentation';
import { Artifact } from './artifact'; import { Artifact } from './artifact';
@ -447,7 +447,7 @@ export class Page extends SdkObject {
return await this._screenshotter.screenshotPage(progress, options.screenshotOptions || {}); return await this._screenshotter.screenshotPage(progress, options.screenshotOptions || {});
}; };
const comparator = mimeTypeToComparator['image/png']; const comparator = getComparator('image/png');
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
const isGeneratingNewScreenshot = !options.expected; const isGeneratingNewScreenshot = !options.expected;
if (isGeneratingNewScreenshot && options.isNot) if (isGeneratingNewScreenshot && options.isNot)

View file

@ -222,8 +222,18 @@ export class Screenshotter {
})); }));
} }
async _maskElements(progress: Progress, options: ScreenshotOptions) { async _maskElements(progress: Progress, options: ScreenshotOptions): Promise<() => Promise<void>> {
const framesToParsedSelectors: MultiMap<Frame, ParsedSelector> = new MultiMap(); const framesToParsedSelectors: MultiMap<Frame, ParsedSelector> = new MultiMap();
const cleanup = async () => {
await Promise.all([...framesToParsedSelectors.keys()].map(async frame => {
await frame.hideHighlight();
}));
};
if (!options.mask || !options.mask.length)
return cleanup;
await Promise.all((options.mask || []).map(async ({ frame, selector }) => { await Promise.all((options.mask || []).map(async ({ frame, selector }) => {
const pair = await frame.resolveFrameForSelectorNoWait(selector); const pair = await frame.resolveFrameForSelectorNoWait(selector);
if (pair) if (pair)
@ -234,7 +244,8 @@ export class Screenshotter {
await Promise.all([...framesToParsedSelectors.keys()].map(async frame => { await Promise.all([...framesToParsedSelectors.keys()].map(async frame => {
await frame.maskSelectors(framesToParsedSelectors.get(frame)); await frame.maskSelectors(framesToParsedSelectors.get(frame));
})); }));
progress.cleanupWhenAborted(() => this._page.hideHighlight()); progress.cleanupWhenAborted(cleanup);
return cleanup;
} }
private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean | undefined, options: ScreenshotOptions): Promise<Buffer> { private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean | undefined, options: ScreenshotOptions): Promise<Buffer> {
@ -248,13 +259,13 @@ export class Screenshotter {
} }
progress.throwIfAborted(); // Avoid extra work. progress.throwIfAborted(); // Avoid extra work.
await this._maskElements(progress, options); const cleanupHighlight = await this._maskElements(progress, options);
progress.throwIfAborted(); // Avoid extra work. progress.throwIfAborted(); // Avoid extra work.
const buffer = await this._page._delegate.takeScreenshot(progress, format, documentRect, viewportRect, options.quality, fitsViewport); const buffer = await this._page._delegate.takeScreenshot(progress, format, documentRect, viewportRect, options.quality, fitsViewport);
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
await this._page.hideHighlight(); await cleanupHighlight();
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
if (shouldSetDefaultBackground) if (shouldSetDefaultBackground)

View file

@ -92,7 +92,7 @@ export class Selectors {
} }
async _queryCount(frame: frames.Frame, info: SelectorInfo, scope?: dom.ElementHandle): Promise<number> { async _queryCount(frame: frames.Frame, info: SelectorInfo, scope?: dom.ElementHandle): Promise<number> {
const context = await frame._utilityContext(); const context = await frame._context(info.world);
const injectedScript = await context.injectedScript(); const injectedScript = await context.injectedScript();
return await injectedScript.evaluate((injected, { parsed, scope }) => { return await injectedScript.evaluate((injected, { parsed, scope }) => {
return injected.querySelectorAll(parsed, scope || document).length; return injected.querySelectorAll(parsed, scope || document).length;

View file

@ -26,12 +26,16 @@ const { PNG } = require(require.resolve('pngjs', { paths: [require.resolve('pixe
export type ImageComparatorOptions = { threshold?: number, maxDiffPixels?: number, maxDiffPixelRatio?: number }; export type ImageComparatorOptions = { threshold?: number, maxDiffPixels?: number, maxDiffPixelRatio?: number };
export type ComparatorResult = { diff?: Buffer; errorMessage?: string; } | null; export type ComparatorResult = { diff?: Buffer; errorMessage?: string; } | null;
export type Comparator = (actualBuffer: Buffer | string, expectedBuffer: Buffer, options?: any) => ComparatorResult; export type Comparator = (actualBuffer: Buffer | string, expectedBuffer: Buffer, options?: any) => ComparatorResult;
export const mimeTypeToComparator: { [key: string]: Comparator } = {
'application/octet-string': compareBuffersOrStrings, export function getComparator(mimeType: string): Comparator {
'image/png': compareImages.bind(null, 'image/png'), if (mimeType === 'image/png')
'image/jpeg': compareImages.bind(null, 'image/jpeg'), return compareImages.bind(null, 'image/png');
'text/plain': compareText, if (mimeType === 'image/jpeg')
}; return compareImages.bind(null, 'image/jpeg');
if (mimeType === 'text/plain')
return compareText;
return compareBuffersOrStrings;
}
function compareBuffersOrStrings(actualBuffer: Buffer | string, expectedBuffer: Buffer): ComparatorResult { function compareBuffersOrStrings(actualBuffer: Buffer | string, expectedBuffer: Buffer): ComparatorResult {
if (typeof actualBuffer === 'string') if (typeof actualBuffer === 'string')

View file

@ -88,6 +88,7 @@ export const deps: any = {
'libxi6', 'libxi6',
'libxrender1', 'libxrender1',
'libxt6', 'libxt6',
'libxtst6',
], ],
webkit: [ webkit: [
'gstreamer1.0-libav', 'gstreamer1.0-libav',

View file

@ -2845,7 +2845,10 @@ export interface Page {
}): Promise<null|Response>; }): Promise<null|Response>;
/** /**
* API testing helper associated with this page. Requests made with this API will use page cookies. * API testing helper associated with this page. This method returns the same instance as
* [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) on the page's
* context. See [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) for
* more details.
*/ */
request: APIRequestContext; request: APIRequestContext;
@ -6612,7 +6615,6 @@ export interface BrowserContext {
* - `'midi'` * - `'midi'`
* - `'midi-sysex'` (system-exclusive midi) * - `'midi-sysex'` (system-exclusive midi)
* - `'notifications'` * - `'notifications'`
* - `'push'`
* - `'camera'` * - `'camera'`
* - `'microphone'` * - `'microphone'`
* - `'background-sync'` * - `'background-sync'`
@ -11826,9 +11828,10 @@ export interface AndroidWebView {
} }
/** /**
* Exposes API that can be used for the Web API testing. Each Playwright browser context has a APIRequestContext instance * Exposes API that can be used for the Web API testing. This class is used for creating [APIRequestContext] instance which
* attached which shares cookies with the page context. Its also possible to create a new APIRequestContext instance * in turn can be used for sending web requests. An instance of this class can be obtained via
* manually. For more information see [here](https://playwright.dev/docs/class-apirequestcontext). * [playwright.request](https://playwright.dev/docs/api/class-playwright#playwright-request). For more information see
* [APIRequestContext].
*/ */
export interface APIRequest { export interface APIRequest {
/** /**
@ -11953,9 +11956,28 @@ export interface APIRequest {
/** /**
* This API is used for the Web API testing. You can use it to trigger API endpoints, configure micro-services, prepare * This API is used for the Web API testing. You can use it to trigger API endpoints, configure micro-services, prepare
* environment or the service to your e2e test. When used on [Page] or a [BrowserContext], this API will automatically use * environment or the service to your e2e test.
* the cookies from the corresponding [BrowserContext]. This means that if you log in using this API, your e2e test will be *
* logged in and vice versa. * Each Playwright browser context has associated with it [APIRequestContext] instance which shares cookie storage with the
* browser context and can be accessed via
* [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) or
* [page.request](https://playwright.dev/docs/api/class-page#page-request). It is also possible to create a new
* APIRequestContext instance manually by calling
* [apiRequest.newContext([options])](https://playwright.dev/docs/api/class-apirequest#api-request-new-context).
*
* **Cookie management**
*
* [APIRequestContext] retuned by
* [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) and
* [page.request](https://playwright.dev/docs/api/class-page#page-request) shares cookie storage with the corresponding
* [BrowserContext]. Each API request will have `Cookie` header populated with the values from the browser context. If the
* API response contains `Set-Cookie` header it will automatically update [BrowserContext] cookies and requests made from
* the page will pick them up. This means that if you log in using this API, your e2e test will be logged in and vice
* versa.
*
* If you want API requests to not interfere with the browser cookies you shoud create a new [APIRequestContext] by calling
* [apiRequest.newContext([options])](https://playwright.dev/docs/api/class-apirequest#api-request-new-context). Such
* `APIRequestContext` object will have its own isolated cookie storage.
* *
*/ */
export interface APIRequestContext { export interface APIRequestContext {

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.20.0-next", "version": "1.20.2",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": "github:Microsoft/playwright", "repository": "github:Microsoft/playwright",
"homepage": "https://playwright.dev", "homepage": "https://playwright.dev",
@ -50,13 +50,13 @@
"debug": "4.3.3", "debug": "4.3.3",
"expect": "27.2.5", "expect": "27.2.5",
"jest-matcher-utils": "27.2.5", "jest-matcher-utils": "27.2.5",
"json5": "2.2.0", "json5": "2.2.1",
"mime": "3.0.0", "mime": "3.0.0",
"minimatch": "3.0.4", "minimatch": "3.0.4",
"ms": "2.1.3", "ms": "2.1.3",
"open": "8.4.0", "open": "8.4.0",
"pirates": "4.0.4", "pirates": "4.0.4",
"playwright-core": "1.20.0-next", "playwright-core": "1.20.2",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"source-map-support": "0.4.18", "source-map-support": "0.4.18",
"stack-utils": "2.0.5", "stack-utils": "2.0.5",

View file

@ -18,6 +18,7 @@
import { Command } from 'commander'; import { Command } from 'commander';
import fs from 'fs'; import fs from 'fs';
import url from 'url';
import path from 'path'; import path from 'path';
import type { Config } from './types'; import type { Config } from './types';
import { Runner, builtInReporters, BuiltInReporter, kDefaultConfigFiles } from './runner'; import { Runner, builtInReporters, BuiltInReporter, kDefaultConfigFiles } from './runner';
@ -245,7 +246,7 @@ function restartWithExperimentalTsEsm(configFile: string | null): boolean {
return false; return false;
if (!fileIsModule(configFile)) if (!fileIsModule(configFile))
return false; return false;
const NODE_OPTIONS = (process.env.NODE_OPTIONS || '') + ` --experimental-loader=${require.resolve('@playwright/test/lib/experimentalLoader')}`; const NODE_OPTIONS = (process.env.NODE_OPTIONS || '') + ` --experimental-loader=${url.pathToFileURL(require.resolve('@playwright/test/lib/experimentalLoader')).toString()}`;
const innerProcess = require('child_process').fork(require.resolve('playwright-core/cli'), process.argv.slice(2), { const innerProcess = require('child_process').fork(require.resolve('playwright-core/cli'), process.argv.slice(2), {
env: { env: {
...process.env, ...process.env,

View file

@ -43,7 +43,7 @@ import {
toHaveURL, toHaveURL,
toHaveValue toHaveValue
} from './matchers/matchers'; } from './matchers/matchers';
import { toMatchSnapshot, toHaveScreenshot } from './matchers/toMatchSnapshot'; import { toMatchSnapshot } from './matchers/toMatchSnapshot';
import type { Expect, TestError } from './types'; import type { Expect, TestError } from './types';
import matchers from 'expect/build/matchers'; import matchers from 'expect/build/matchers';
import { currentTestInfo } from './globals'; import { currentTestInfo } from './globals';
@ -132,7 +132,6 @@ const customMatchers = {
toHaveURL, toHaveURL,
toHaveValue, toHaveValue,
toMatchSnapshot, toMatchSnapshot,
toHaveScreenshot,
}; };
type ExpectMetaInfo = { type ExpectMetaInfo = {

View file

@ -489,7 +489,7 @@ export function folderIsModule(folder: string): boolean {
if (fs.existsSync(packageJson)) { if (fs.existsSync(packageJson)) {
isModule = require(packageJson).type === 'module'; isModule = require(packageJson).type === 'module';
} else { } else {
const parentFolder = path.basename(folder); const parentFolder = path.dirname(folder);
if (parentFolder !== folder) if (parentFolder !== folder)
isModule = folderIsModule(parentFolder); isModule = folderIsModule(parentFolder);
else else

View file

@ -14,14 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
import { Locator, Page } from 'playwright-core';
import type { Page as PageEx } from 'playwright-core/lib/client/page';
import type { Locator as LocatorEx } from 'playwright-core/lib/client/locator';
import type { Expect } from '../types'; import type { Expect } from '../types';
import { currentTestInfo } from '../globals'; import { currentTestInfo } from '../globals';
import { mimeTypeToComparator, ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils/comparators'; import { getComparator, ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils/comparators';
import type { PageScreenshotOptions } from 'playwright-core/types/types'; import { addSuffixToFilePath, serializeError, sanitizeForFilePath, trimLongString, callLogText } from '../util';
import { addSuffixToFilePath, serializeError, sanitizeForFilePath, trimLongString, callLogText, currentExpectTimeout } from '../util';
import { UpdateSnapshots } from '../types'; import { UpdateSnapshots } from '../types';
import colors from 'colors/safe'; import colors from 'colors/safe';
import fs from 'fs'; import fs from 'fs';
@ -48,6 +44,7 @@ class SnapshotHelper<T extends ImageComparatorOptions> {
readonly kind: 'Screenshot'|'Snapshot'; readonly kind: 'Screenshot'|'Snapshot';
readonly updateSnapshots: UpdateSnapshots; readonly updateSnapshots: UpdateSnapshots;
readonly comparatorOptions: ImageComparatorOptions; readonly comparatorOptions: ImageComparatorOptions;
readonly comparator: Comparator;
readonly allOptions: T; readonly allOptions: T;
constructor( constructor(
@ -99,10 +96,7 @@ class SnapshotHelper<T extends ImageComparatorOptions> {
if (updateSnapshots === 'missing' && testInfo.retry < testInfo.project.retries) if (updateSnapshots === 'missing' && testInfo.retry < testInfo.project.retries)
updateSnapshots = 'none'; updateSnapshots = 'none';
const mimeType = mime.getType(path.basename(snapshotPath)) ?? 'application/octet-string'; const mimeType = mime.getType(path.basename(snapshotPath)) ?? 'application/octet-string';
const comparator: Comparator = mimeTypeToComparator[mimeType]; this.comparator = getComparator(mimeType);
if (!comparator)
throw new Error('Failed to find comparator with type ' + mimeType + ': ' + snapshotPath);
this.testInfo = testInfo; this.testInfo = testInfo;
this.mimeType = mimeType; this.mimeType = mimeType;
this.actualPath = actualPath; this.actualPath = actualPath;
@ -206,13 +200,11 @@ class SnapshotHelper<T extends ImageComparatorOptions> {
} }
} }
type MatchSnapshotOptions = Omit<ImageComparatorOptions, 'maxDiffPixels' | 'maxDiffPixelRatio'>;
export function toMatchSnapshot( export function toMatchSnapshot(
this: ReturnType<Expect['getState']>, this: ReturnType<Expect['getState']>,
received: Buffer | string, received: Buffer | string,
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & MatchSnapshotOptions = {}, nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ImageComparatorOptions = {},
optOptions: MatchSnapshotOptions = {} optOptions: ImageComparatorOptions = {}
): SyncExpectationResult { ): SyncExpectationResult {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
if (!testInfo) if (!testInfo)
@ -221,14 +213,11 @@ export function toMatchSnapshot(
testInfo, determineFileExtension(received), testInfo, determineFileExtension(received),
testInfo.project.expect?.toMatchSnapshot || {}, testInfo.project.expect?.toMatchSnapshot || {},
nameOrOptions, optOptions); nameOrOptions, optOptions);
const comparator: Comparator = mimeTypeToComparator[helper.mimeType];
if (!comparator)
throw new Error('Failed to find comparator with type ' + helper.mimeType + ': ' + helper.snapshotPath);
if (this.isNot) { if (this.isNot) {
if (!fs.existsSync(helper.snapshotPath)) if (!fs.existsSync(helper.snapshotPath))
return helper.handleMissingNegated(); return helper.handleMissingNegated();
const isDifferent = !!comparator(received, fs.readFileSync(helper.snapshotPath), helper.comparatorOptions); const isDifferent = !!helper.comparator(received, fs.readFileSync(helper.snapshotPath), helper.comparatorOptions);
return isDifferent ? helper.handleDifferentNegated() : helper.handleMatchingNegated(); return isDifferent ? helper.handleDifferentNegated() : helper.handleMatchingNegated();
} }
@ -236,11 +225,7 @@ export function toMatchSnapshot(
return helper.handleMissing(received); return helper.handleMissing(received);
const expected = fs.readFileSync(helper.snapshotPath); const expected = fs.readFileSync(helper.snapshotPath);
const result = comparator(received, expected, { const result = helper.comparator(received, expected, helper.comparatorOptions);
...helper.comparatorOptions,
maxDiffPixels: undefined,
maxDiffPixelRatio: undefined,
});
if (!result) if (!result)
return helper.handleMatching(); return helper.handleMatching();
@ -254,107 +239,6 @@ export function toMatchSnapshot(
return helper.handleDifferent(received, expected, result.diff, result.errorMessage, undefined); return helper.handleDifferent(received, expected, result.diff, result.errorMessage, undefined);
} }
type HaveScreenshotOptions = ImageComparatorOptions & Omit<PageScreenshotOptions, 'type' | 'quality' | 'path'>;
export async function toHaveScreenshot(
this: ReturnType<Expect['getState']>,
pageOrLocator: Page | Locator,
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & HaveScreenshotOptions = {},
optOptions: HaveScreenshotOptions = {}
): Promise<SyncExpectationResult> {
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`toHaveScreenshot() must be called during the test`);
const helper = new SnapshotHelper(
testInfo, 'png',
testInfo.project.expect?.toHaveScreenshot || {},
nameOrOptions, optOptions);
const [page, locator] = pageOrLocator.constructor.name === 'Page' ? [(pageOrLocator as PageEx), undefined] : [(pageOrLocator as Locator).page() as PageEx, pageOrLocator as LocatorEx];
const screenshotOptions = {
...helper.allOptions,
mask: (helper.allOptions.mask || []) as LocatorEx[],
name: undefined,
threshold: undefined,
maxDiffPixels: undefined,
maxDiffPixelRatio: undefined,
};
const hasSnapshot = fs.existsSync(helper.snapshotPath);
if (this.isNot) {
if (!hasSnapshot)
return helper.handleMissingNegated();
// Having `errorMessage` means we timed out while waiting
// for screenshots not to match, so screenshots
// are actually the same in the end.
const isDifferent = !(await page._expectScreenshot({
expected: await fs.promises.readFile(helper.snapshotPath),
isNot: true,
locator,
comparatorOptions: helper.comparatorOptions,
screenshotOptions,
timeout: currentExpectTimeout(helper.allOptions),
})).errorMessage;
return isDifferent ? helper.handleDifferentNegated() : helper.handleMatchingNegated();
}
// Fast path: there's no screenshot and we don't intend to update it.
if (helper.updateSnapshots === 'none' && !hasSnapshot)
return { pass: false, message: () => `${helper.snapshotPath} is missing in snapshots.` };
if (helper.updateSnapshots === 'all' || !hasSnapshot) {
// Regenerate a new screenshot by waiting until two screenshots are the same.
const timeout = currentExpectTimeout(helper.allOptions);
const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot({
expected: undefined,
isNot: false,
locator,
comparatorOptions: helper.comparatorOptions,
screenshotOptions,
timeout,
});
// We tried re-generating new snapshot but failed.
// This can be due to e.g. spinning animation, so we want to show it as a diff.
if (errorMessage) {
// TODO(aslushnikov): rename attachments to "actual" and "previous". They still should be somehow shown in HTML reporter.
const title = actual && previous ?
`Timeout ${timeout}ms exceeded while generating screenshot because ${locator ? 'element' : 'page'} kept changing:` :
`Timeout ${timeout}ms exceeded while generating screenshot:`;
return helper.handleDifferent(actual, previous, diff, undefined, log, title);
}
// We successfully (re-)generated new screenshot.
if (!hasSnapshot)
return helper.handleMissing(actual!);
writeFileSync(helper.snapshotPath, actual!);
/* eslint-disable no-console */
console.log(helper.snapshotPath + ' is re-generated, writing actual.');
return {
pass: true,
message: () => helper.snapshotPath + ' running with --update-snapshots, writing actual.'
};
}
// General case:
// - snapshot exists
// - regular matcher (i.e. not a `.not`)
// - no flags to update screenshots
const expected = await fs.promises.readFile(helper.snapshotPath);
const { actual, diff, errorMessage, log } = await page._expectScreenshot({
expected,
isNot: false,
locator,
comparatorOptions: helper.comparatorOptions,
screenshotOptions,
timeout: currentExpectTimeout(helper.allOptions),
});
return errorMessage ?
helper.handleDifferent(actual, expected, diff, errorMessage, log) :
helper.handleMatching();
}
function writeFileSync(aPath: string, content: Buffer | string) { function writeFileSync(aPath: string, content: Buffer | string) {
fs.mkdirSync(path.dirname(aPath), { recursive: true }); fs.mkdirSync(path.dirname(aPath), { recursive: true });
fs.writeFileSync(aPath, content); fs.writeFileSync(aPath, content);

View file

@ -313,7 +313,7 @@ export class Runner {
fatalErrors.push(createNoTestsError()); fatalErrors.push(createNoTestsError());
// 8. Compute shards. // 8. Compute shards.
let testGroups = createTestGroups(rootSuite); let testGroups = createTestGroups(rootSuite, config.workers);
const shard = config.shard; const shard = config.shard;
if (shard) { if (shard) {
@ -619,7 +619,7 @@ function buildItemLocation(rootDir: string, testOrSuite: Suite | TestCase) {
return `${path.relative(rootDir, testOrSuite.location.file)}:${testOrSuite.location.line}`; return `${path.relative(rootDir, testOrSuite.location.file)}:${testOrSuite.location.line}`;
} }
function createTestGroups(rootSuite: Suite): TestGroup[] { function createTestGroups(rootSuite: Suite, workers: number): TestGroup[] {
// This function groups tests that can be run together. // This function groups tests that can be run together.
// Tests cannot be run together when: // Tests cannot be run together when:
// - They belong to different projects - requires different workers. // - They belong to different projects - requires different workers.
@ -630,7 +630,15 @@ function createTestGroups(rootSuite: Suite): TestGroup[] {
// Using the map "workerHash -> requireFile -> group" makes us preserve the natural order // Using the map "workerHash -> requireFile -> group" makes us preserve the natural order
// of worker hashes and require files for the simple cases. // of worker hashes and require files for the simple cases.
const groups = new Map<string, Map<string, { general: TestGroup, parallel: TestGroup[] }>>(); const groups = new Map<string, Map<string, {
// Tests that must be run in order are in the same group.
general: TestGroup,
// Tests that may be run independently each has a dedicated group with a single test.
parallel: TestGroup[],
// Tests that are marked as parallel but have beforeAll/afterAll hooks should be grouped
// as much as possible. We split them into equally sized groups, one per worker.
parallelWithHooks: TestGroup,
}>>();
const createGroup = (test: TestCase): TestGroup => { const createGroup = (test: TestCase): TestGroup => {
return { return {
@ -654,18 +662,26 @@ function createTestGroups(rootSuite: Suite): TestGroup[] {
withRequireFile = { withRequireFile = {
general: createGroup(test), general: createGroup(test),
parallel: [], parallel: [],
parallelWithHooks: createGroup(test),
}; };
withWorkerHash.set(test._requireFile, withRequireFile); withWorkerHash.set(test._requireFile, withRequireFile);
} }
let insideParallel = false; let insideParallel = false;
for (let parent: Suite | undefined = test.parent; parent; parent = parent.parent) let hasAllHooks = false;
for (let parent: Suite | undefined = test.parent; parent; parent = parent.parent) {
insideParallel = insideParallel || parent._parallelMode === 'parallel'; insideParallel = insideParallel || parent._parallelMode === 'parallel';
hasAllHooks = hasAllHooks || parent.hooks.length > 0;
}
if (insideParallel) { if (insideParallel) {
const group = createGroup(test); if (hasAllHooks) {
group.tests.push(test); withRequireFile.parallelWithHooks.tests.push(test);
withRequireFile.parallel.push(group); } else {
const group = createGroup(test);
group.tests.push(test);
withRequireFile.parallel.push(group);
}
} else { } else {
withRequireFile.general.tests.push(test); withRequireFile.general.tests.push(test);
} }
@ -678,6 +694,16 @@ function createTestGroups(rootSuite: Suite): TestGroup[] {
if (withRequireFile.general.tests.length) if (withRequireFile.general.tests.length)
result.push(withRequireFile.general); result.push(withRequireFile.general);
result.push(...withRequireFile.parallel); result.push(...withRequireFile.parallel);
const parallelWithHooksGroupSize = Math.ceil(withRequireFile.parallelWithHooks.tests.length / workers);
let lastGroup: TestGroup | undefined;
for (const test of withRequireFile.parallelWithHooks.tests) {
if (!lastGroup || lastGroup.tests.length >= parallelWithHooksGroupSize) {
lastGroup = createGroup(test);
result.push(lastGroup);
}
lastGroup.tests.push(test);
}
} }
} }
return result; return result;

View file

@ -99,25 +99,33 @@ export function resolveHook(filename: string, specifier: string): string | undef
if (!isTypeScript) if (!isTypeScript)
return; return;
const tsconfig = loadAndValidateTsconfigForFile(filename); const tsconfig = loadAndValidateTsconfigForFile(filename);
if (!tsconfig) if (tsconfig) {
return; for (const { key, values } of tsconfig.paths) {
for (const { key, values } of tsconfig.paths) { const keyHasStar = key[key.length - 1] === '*';
const keyHasStar = key[key.length - 1] === '*'; const matches = specifier.startsWith(keyHasStar ? key.substring(0, key.length - 1) : key);
const matches = specifier.startsWith(keyHasStar ? key.substring(0, key.length - 1) : key); if (!matches)
if (!matches) continue;
continue; for (const value of values) {
for (const value of values) { const valueHasStar = value[value.length - 1] === '*';
const valueHasStar = value[value.length - 1] === '*'; let candidate = valueHasStar ? value.substring(0, value.length - 1) : value;
let candidate = valueHasStar ? value.substring(0, value.length - 1) : value; if (valueHasStar && keyHasStar)
if (valueHasStar && keyHasStar) candidate += specifier.substring(key.length - 1);
candidate += specifier.substring(key.length - 1); candidate = path.resolve(tsconfig.absoluteBaseUrl, candidate.replace(/\//g, path.sep));
candidate = path.resolve(tsconfig.absoluteBaseUrl, candidate.replace(/\//g, path.sep)); for (const ext of ['', '.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx']) {
for (const ext of ['', '.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx']) { if (fs.existsSync(candidate + ext))
if (fs.existsSync(candidate + ext)) return candidate;
return candidate; }
} }
} }
} }
if (specifier.endsWith('.js')) {
const resolved = path.resolve(path.dirname(filename), specifier);
if (resolved.endsWith('.js')) {
const tsResolved = resolved.substring(0, resolved.length - 3) + '.ts';
if (!fs.existsSync(resolved) && fs.existsSync(tsResolved))
return tsResolved;
}
}
} }
export function transformHook(code: string, filename: string, isModule = false): string { export function transformHook(code: string, filename: string, isModule = false): string {

View file

@ -43,7 +43,7 @@ type ExpectSettings = {
* Default timeout for async expect matchers in milliseconds, defaults to 5000ms. * Default timeout for async expect matchers in milliseconds, defaults to 5000ms.
*/ */
timeout?: number; timeout?: number;
toHaveScreenshot?: { toMatchSnapshot?: {
/** An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between pixels in compared images, between zero (strict) and one (lax). Defaults to `0.2`. /** An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between pixels in compared images, between zero (strict) and one (lax). Defaults to `0.2`.
*/ */
threshold?: number, threshold?: number,
@ -56,11 +56,6 @@ type ExpectSettings = {
*/ */
maxDiffPixelRatio?: number, maxDiffPixelRatio?: number,
} }
toMatchSnapshot?: {
/** An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between pixels in compared images, between zero (strict) and one (lax). Defaults to `0.2`.
*/
threshold?: number,
}
}; };
/** /**
@ -162,8 +157,7 @@ interface TestProject {
*/ */
name?: string; name?: string;
/** /**
* The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot` and * The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot`. Defaults to
* `toHaveScreenshot`. Defaults to
* [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir). * [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir).
* *
* The directory for each test can be accessed by * The directory for each test can be accessed by
@ -701,7 +695,7 @@ interface TestConfig {
* const config: PlaywrightTestConfig = { * const config: PlaywrightTestConfig = {
* expect: { * expect: {
* timeout: 10000, * timeout: 10000,
* toHaveScreenshot: { * toMatchSnapshot: {
* maxDiffPixels: 10, * maxDiffPixels: 10,
* }, * },
* }, * },
@ -717,8 +711,7 @@ interface TestConfig {
metadata?: any; metadata?: any;
name?: string; name?: string;
/** /**
* The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot` and * The base directory, relative to the config file, for snapshot files created with `toMatchSnapshot`. Defaults to
* `toHaveScreenshot`. Defaults to
* [testConfig.testDir](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir). * [testConfig.testDir](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir).
* *
* The directory for each test can be accessed by * The directory for each test can be accessed by
@ -1572,8 +1565,8 @@ export interface TestInfo {
/** /**
* Suffix used to differentiate snapshots between multiple test configurations. For example, if snapshots depend on the * Suffix used to differentiate snapshots between multiple test configurations. For example, if snapshots depend on the
* platform, you can set `testInfo.snapshotSuffix` equal to `process.platform`. In this case both * platform, you can set `testInfo.snapshotSuffix` equal to `process.platform`. In this case both
* `expect(value).toMatchSnapshot(snapshotName)` and `expect(page).toHaveScreenshot(snapshotName)` will use different * `expect(value).toMatchSnapshot(snapshotName)` will use different snapshots depending on the platform. Learn more about
* snapshots depending on the platform. Learn more about [snapshots](https://playwright.dev/docs/test-snapshots). * [snapshots](https://playwright.dev/docs/test-snapshots).
*/ */
snapshotSuffix: string; snapshotSuffix: string;
/** /**

View file

@ -34,6 +34,8 @@ export declare type Expect = {
extend(arg0: any): void; extend(arg0: any): void;
getState(): expect.MatcherState; getState(): expect.MatcherState;
setState(state: Partial<expect.MatcherState>): void; setState(state: Partial<expect.MatcherState>): void;
any(expectedObject: any): AsymmetricMatcher;
anything(): AsymmetricMatcher;
arrayContaining(sample: Array<unknown>): AsymmetricMatcher; arrayContaining(sample: Array<unknown>): AsymmetricMatcher;
objectContaining(sample: Record<string, unknown>): AsymmetricMatcher; objectContaining(sample: Record<string, unknown>): AsymmetricMatcher;
stringContaining(expected: string): AsymmetricMatcher; stringContaining(expected: string): AsymmetricMatcher;
@ -43,8 +45,6 @@ export declare type Expect = {
* - assertions() * - assertions()
* - extractExpectedAssertionsErrors() * - extractExpectedAssertionsErrors()
* hasAssertions() * hasAssertions()
* - any()
* - anything()
*/ */
}; };
@ -222,18 +222,6 @@ interface LocatorMatchers {
* Asserts given DOM node visible on the screen. * Asserts given DOM node visible on the screen.
*/ */
toBeVisible(options?: { timeout?: number }): Promise<Locator>; toBeVisible(options?: { timeout?: number }): Promise<Locator>;
/**
* Asserts element's screenshot is matching to the snapshot.
*/
toHaveScreenshot(options?: Omit<LocatorScreenshotOptions, 'path' | 'type' | 'quality'> & ImageComparatorOptions & {
name?: string | string[],
}): Promise<Locator>;
/**
* Asserts element's screenshot is matching to the snapshot.
*/
toHaveScreenshot(name: string | string[], options?: Omit<LocatorScreenshotOptions, 'path' | 'type' | 'quality'> & ImageComparatorOptions): Promise<Locator>;
} }
interface PageMatchers { interface PageMatchers {
/** /**
@ -245,18 +233,6 @@ interface PageMatchers {
* Asserts page's URL. * Asserts page's URL.
*/ */
toHaveURL(expected: string | RegExp, options?: { timeout?: number }): Promise<Page>; toHaveURL(expected: string | RegExp, options?: { timeout?: number }): Promise<Page>;
/**
* Asserts page screenshot is matching to the snapshot.
*/
toHaveScreenshot(options?: Omit<PageScreenshotOptions, 'path' | 'quality' | 'type'> & ImageComparatorOptions & {
name?: string | string[],
}): Promise<Page>;
/**
* Asserts page screenshot is matching to the snapshot.
*/
toHaveScreenshot(name: string | string[], options?: Omit<PageScreenshotOptions, 'path' | 'quality' | 'type'> & ImageComparatorOptions): Promise<Page>;
} }
interface APIResponseMatchers { interface APIResponseMatchers {

View file

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

View file

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

View file

@ -184,3 +184,62 @@ it('should work with drag and drop that moves the element under cursor', async (
await page.dragAndDrop('#from', '#to'); await page.dragAndDrop('#from', '#to');
await expect(page.locator('#to')).toHaveText('Dropped'); await expect(page.locator('#to')).toHaveText('Dropped');
}); });
it('should work with block inside inline', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`
<div>
<span>
<div id="target" onclick="window._clicked=true">
Romimine
</div>
</span>
</div>
`);
await page.locator('#target').click();
expect(await page.evaluate('window._clicked')).toBe(true);
});
it('should work with block-block-block inside inline-inline', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`
<div>
<a href="#ney">
<div>
<span>
<a href="#yay">
<div>
<h3 id="target">
Romimine
</h3>
</div>
</a>
</span>
</div>
</a>
</div>
`);
await page.locator('#target').click();
await expect(page).toHaveURL(server.EMPTY_PAGE + '#yay');
});
it('should work with block inside inline in shadow dom', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`
<div>
</div>
<script>
const root = document.querySelector('div');
const shadowRoot = root.attachShadow({ mode: 'open' });
const span = document.createElement('span');
shadowRoot.appendChild(span);
const div = document.createElement('div');
span.appendChild(div);
div.id = 'target';
div.addEventListener('click', () => window._clicked = true);
div.textContent = 'Hello';
</script>
`);
await page.locator('#target').click();
expect(await page.evaluate('window._clicked')).toBe(true);
});

View file

@ -17,6 +17,7 @@
import { test as it, expect } from './pageTest'; import { test as it, expect } from './pageTest';
import { verifyViewport, attachFrame } from '../config/utils'; import { verifyViewport, attachFrame } from '../config/utils';
import type { Route } from 'playwright-core';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
@ -429,6 +430,33 @@ it.describe('page screenshot', () => {
const screenshot2 = await page.screenshot(); const screenshot2 = await page.screenshot();
expect(screenshot1.equals(screenshot2)).toBe(true); expect(screenshot1.equals(screenshot2)).toBe(true);
}); });
it('should work when subframe has stalled navigation', async ({ page, server }) => {
let cb;
const routeReady = new Promise<Route>(f => cb = f);
await page.route('**/subframe.html', cb); // Stalling subframe.
await page.goto(server.EMPTY_PAGE);
const done = page.setContent(`<iframe src='/subframe.html'></iframe>`);
const route = await routeReady;
await page.screenshot({ mask: [ page.locator('non-existent') ] });
await route.fulfill({ body: '' });
await done;
});
it('should work when subframe used document.open after a weird url', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => {
const iframe = document.createElement('iframe');
iframe.src = 'javascript:hi';
document.body.appendChild(iframe);
iframe.contentDocument.open();
iframe.contentDocument.write('Hello');
iframe.contentDocument.close();
});
await page.screenshot({ mask: [ page.locator('non-existent') ] });
});
}); });
}); });

View file

@ -30,10 +30,14 @@ for (const [name, url] of Object.entries(vues)) {
it('should work with single-root elements #smoke', async ({ page }) => { it('should work with single-root elements #smoke', async ({ page }) => {
expect(await page.$$eval(`_vue=book-list`, els => els.length)).toBe(1); expect(await page.$$eval(`_vue=book-list`, els => els.length)).toBe(1);
expect(await page.locator(`_vue=book-list`).count()).toBe(1);
await expect(page.locator(`_vue=book-list`)).toHaveCount(1);
expect(await page.$$eval(`_vue=book-item`, els => els.length)).toBe(3); expect(await page.$$eval(`_vue=book-item`, els => els.length)).toBe(3);
expect(await page.locator(`_vue=book-item`).count()).toBe(3);
await expect(page.locator(`_vue=book-item`)).toHaveCount(3);
expect(await page.$$eval(`_vue=book-list >> _vue=book-item`, els => els.length)).toBe(3); expect(await page.$$eval(`_vue=book-list >> _vue=book-item`, els => els.length)).toBe(3);
expect(await page.locator(`_vue=book-list >> _vue=book-item`).count()).toBe(3);
expect(await page.$$eval(`_vue=book-item >> _vue=book-list`, els => els.length)).toBe(0); expect(await page.$$eval(`_vue=book-item >> _vue=book-list`, els => els.length)).toBe(0);
}); });
it('should work with multi-root elements (fragments)', async ({ page }) => { it('should work with multi-root elements (fragments)', async ({ page }) => {

View file

@ -0,0 +1,126 @@
/**
* 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 './playwright-test-fixtures';
// Note: tests from this file are additionally run on Node16 bots.
test('should load nested as esm when package.json has type module', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.js': `
//@no-header
import * as fs from 'fs';
export default { projects: [{name: 'foo'}] };
`,
'package.json': JSON.stringify({ type: 'module' }),
'nested/folder/a.esm.test.js': `
const { test } = pwt;
test('check project name', ({}, testInfo) => {
expect(testInfo.project.name).toBe('foo');
});
`
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should import esm from ts when package.json has type module in experimental mode', async ({ runInlineTest }) => {
// We only support experimental esm mode on Node 16+
test.skip(parseInt(process.version.slice(1), 10) < 16);
const result = await runInlineTest({
'playwright.config.ts': `
import * as fs from 'fs';
export default { projects: [{name: 'foo'}] };
`,
'package.json': JSON.stringify({ type: 'module' }),
'a.test.ts': `
import { foo } from './b.ts';
import { bar } from './c.js';
import { qux } from './d.js';
const { test } = pwt;
test('check project name', ({}, testInfo) => {
expect(testInfo.project.name).toBe('foo');
expect(bar).toBe('bar');
expect(qux).toBe('qux');
});
`,
'b.ts': `
export const foo: string = 'foo';
`,
'c.ts': `
export const bar: string = 'bar';
`,
'd.js': `
//@no-header
export const qux = 'qux';
`,
}, {}, { PW_EXPERIMENTAL_TS_ESM: true });
expect(result.exitCode).toBe(0);
});
test('should propagate subprocess exit code in experimental mode', async ({ runInlineTest }) => {
// We only support experimental esm mode on Node 16+
test.skip(parseInt(process.version.slice(1), 10) < 16);
const result = await runInlineTest({
'package.json': JSON.stringify({ type: 'module' }),
'a.test.ts': `
const { test } = pwt;
test('failing test', ({}, testInfo) => {
expect(1).toBe(2);
});
`,
}, {}, { PW_EXPERIMENTAL_TS_ESM: true });
expect(result.exitCode).toBe(1);
});
test('should respect path resolver in experimental mode', async ({ runInlineTest }) => {
// We only support experimental esm mode on Node 16+
test.skip(parseInt(process.version.slice(1), 10) < 16);
const result = await runInlineTest({
'package.json': JSON.stringify({ type: 'module' }),
'playwright.config.ts': `
export default {
projects: [{name: 'foo'}],
};
`,
'tsconfig.json': `{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["esnext", "dom", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"util/*": ["./foo/bar/util/*"],
},
},
}`,
'a.test.ts': `
import { foo } from 'util/b.ts';
const { test } = pwt;
test('check project name', ({}, testInfo) => {
expect(testInfo.project.name).toBe(foo);
});
`,
'foo/bar/util/b.ts': `
export const foo: string = 'foo';
`,
}, {}, { PW_EXPERIMENTAL_TS_ESM: true });
expect(result.exitCode).toBe(0);
});

View file

@ -118,17 +118,31 @@ test('should include custom error message with web-first assertions', async ({ r
].join('\n')); ].join('\n'));
}); });
test('should work with default expect prototype functions', async ({ runTSC }) => { test('should work with default expect prototype functions', async ({ runTSC, runInlineTest }) => {
const result = await runTSC({ const spec = `
'a.spec.ts': ` const { test } = pwt;
const { test } = pwt; test('pass', async () => {
const expected = [1, 2, 3, 4, 5, 6]; const expected = [1, 2, 3, 4, 5, 6];
test.expect([4, 1, 6, 7, 3, 5, 2, 5, 4, 6]).toEqual( test.expect([4, 1, 6, 7, 3, 5, 2, 5, 4, 6]).toEqual(
expect.arrayContaining(expected), expect.arrayContaining(expected),
); );
` expect('foo').toEqual(expect.any(String));
}); expect('foo').toEqual(expect.anything());
expect(result.exitCode).toBe(0); });
`;
{
const result = await runTSC({
'a.spec.ts': spec,
});
expect(result.exitCode).toBe(0);
}
{
const result = await runInlineTest({
'a.spec.ts': spec,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
}
}); });
test('should work with default expect matchers', async ({ runTSC }) => { test('should work with default expect matchers', async ({ runTSC }) => {

View file

@ -17,7 +17,7 @@
import colors from 'colors/safe'; import colors from 'colors/safe';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { test, expect, stripAnsi } from './playwright-test-fixtures'; import { test, expect, stripAnsi, createWhiteImage, paintBlackPixels } from './playwright-test-fixtures';
const files = { const files = {
'helper.ts': ` 'helper.ts': `
@ -44,6 +44,22 @@ test('should support golden', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
}); });
test('should work with non-txt extensions', async ({ runInlineTest }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.csv': `1,2,3`,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', ({}) => {
expect('1,2,4').toMatchSnapshot('snapshot.csv');
});
`
});
expect(result.exitCode).toBe(1);
expect(stripAnsi(result.output)).toContain(`1,2,34`);
});
test('should generate default name', async ({ runInlineTest }, testInfo) => { test('should generate default name', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
...files, ...files,
@ -79,6 +95,8 @@ test('should compile with different option combinations', async ({ runTSC }) =>
test('is a test', async ({ page }) => { test('is a test', async ({ page }) => {
expect('foo').toMatchSnapshot(); expect('foo').toMatchSnapshot();
expect('foo').toMatchSnapshot({ threshold: 0.2 }); expect('foo').toMatchSnapshot({ threshold: 0.2 });
expect('foo').toMatchSnapshot({ maxDiffPixelRatio: 0.2 });
expect('foo').toMatchSnapshot({ maxDiffPixels: 0.2 });
}); });
` `
}); });
@ -394,6 +412,106 @@ test('should compare binary', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
}); });
test('should respect maxDiffPixels option', async ({ runInlineTest }) => {
const width = 20, height = 20;
const BAD_PIXELS = 120;
const image1 = createWhiteImage(width, height);
const image2 = paintBlackPixels(image1, BAD_PIXELS);
await test.step('make sure default comparison fails', async () => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': image1,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', ({}) => {
expect(Buffer.from('${image2.toString('base64')}', 'base64')).toMatchSnapshot('snapshot.png');
});
`
});
expect(stripAnsi(result.output)).toContain('120 pixels');
expect(stripAnsi(result.output)).toContain('ratio 0.30');
expect(result.exitCode).toBe(1);
});
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': image1,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', ({}) => {
expect(Buffer.from('${image2.toString('base64')}', 'base64')).toMatchSnapshot('snapshot.png', {
maxDiffPixels: ${BAD_PIXELS}
});
});
`
})).exitCode, 'make sure maxDiffPixels option is respected').toBe(0);
expect((await runInlineTest({
...files,
'playwright.config.ts': `
module.exports = { projects: [
{ expect: { toMatchSnapshot: { maxDiffPixels: ${BAD_PIXELS} } } },
]};
`,
'a.spec.js-snapshots/snapshot.png': image1,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', ({}) => {
expect(Buffer.from('${image2.toString('base64')}', 'base64')).toMatchSnapshot('snapshot.png');
});
`
})).exitCode, 'make sure maxDiffPixels option in project config is respected').toBe(0);
});
test('should respect maxDiffPixelRatio option', async ({ runInlineTest }) => {
const width = 20, height = 20;
const BAD_RATIO = 0.25;
const BAD_PIXELS = Math.floor(width * height * BAD_RATIO);
const image1 = createWhiteImage(width, height);
const image2 = paintBlackPixels(image1, BAD_PIXELS);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': image1,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', ({}) => {
expect(Buffer.from('${image2.toString('base64')}', 'base64')).toMatchSnapshot('snapshot.png');
});
`
})).exitCode, 'make sure default comparison fails').toBe(1);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': image1,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', ({}) => {
expect(Buffer.from('${image2.toString('base64')}', 'base64')).toMatchSnapshot('snapshot.png', {
maxDiffPixelRatio: ${BAD_RATIO}
});
});
`
})).exitCode, 'make sure maxDiffPixelRatio option is respected').toBe(0);
expect((await runInlineTest({
...files,
'playwright.config.ts': `
module.exports = { projects: [
{ expect: { toMatchSnapshot: { maxDiffPixelRatio: ${BAD_RATIO} } } },
]};
`,
'a.spec.js-snapshots/snapshot.png': image1,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', ({}) => {
expect(Buffer.from('${image2.toString('base64')}', 'base64')).toMatchSnapshot('snapshot.png');
});
`
})).exitCode, 'make sure maxDiffPixels option in project config is respected').toBe(0);
});
test('should compare PNG images', async ({ runInlineTest }) => { test('should compare PNG images', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
...files, ...files,
@ -417,11 +535,7 @@ test('should compare different PNG images', async ({ runInlineTest }, testInfo)
'a.spec.js': ` 'a.spec.js': `
const { test } = require('./helper'); const { test } = require('./helper');
test('is a test', ({}) => { test('is a test', ({}) => {
expect(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII==', 'base64')).toMatchSnapshot('snapshot.png', { expect(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII==', 'base64')).toMatchSnapshot('snapshot.png');
// make sure maxDiffPixelRatio is *not* respected.
// See https://github.com/microsoft/playwright/issues/12564
maxDiffPixelRatio: 1.0,
});
}); });
` `
}); });

View file

@ -241,50 +241,6 @@ test('should fail to load ts from esm when package.json has type module', async
expect(result.output).toContain('Cannot import a typescript file from an esmodule'); expect(result.output).toContain('Cannot import a typescript file from an esmodule');
}); });
test('should import esm from ts when package.json has type module in experimental mode', async ({ runInlineTest }) => {
// We only support experimental esm mode on Node 16+
test.skip(parseInt(process.version.slice(1), 10) < 16);
const result = await runInlineTest({
'playwright.config.ts': `
import * as fs from 'fs';
export default { projects: [{name: 'foo'}] };
`,
'package.json': JSON.stringify({ type: 'module' }),
'a.test.ts': `
import { foo } from './b.ts';
const { test } = pwt;
test('check project name', ({}, testInfo) => {
expect(testInfo.project.name).toBe('foo');
});
`,
'b.ts': `
export const foo: string = 'foo';
`
}, {}, {
PW_EXPERIMENTAL_TS_ESM: true
});
expect(result.exitCode).toBe(0);
});
test('should propagate subprocess exit code in experimental mode', async ({ runInlineTest }) => {
// We only support experimental esm mode on Node 16+
test.skip(parseInt(process.version.slice(1), 10) < 16);
const result = await runInlineTest({
'package.json': JSON.stringify({ type: 'module' }),
'a.test.ts': `
const { test } = pwt;
test('failing test', ({}, testInfo) => {
expect(1).toBe(2);
});
`,
}, {}, {
PW_EXPERIMENTAL_TS_ESM: true
});
expect(result.exitCode).toBe(1);
});
test('should filter stack trace for simple expect', async ({ runInlineTest }) => { test('should filter stack trace for simple expect', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'expect-test.spec.ts': ` 'expect-test.spec.ts': `

View file

@ -241,7 +241,8 @@ const TSCONFIG = {
'esModuleInterop': true, 'esModuleInterop': true,
'allowSyntheticDefaultImports': true, 'allowSyntheticDefaultImports': true,
'rootDir': '.', 'rootDir': '.',
'lib': ['esnext', 'dom', 'DOM.Iterable'] 'lib': ['esnext', 'dom', 'DOM.Iterable'],
'noEmit': true,
}, },
'exclude': [ 'exclude': [
'node_modules' 'node_modules'

View file

@ -163,41 +163,3 @@ test('should respect baseurl w/o paths', async ({ runInlineTest }) => {
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
expect(result.output).not.toContain(`Could not`); expect(result.output).not.toContain(`Could not`);
}); });
test('should respect path resolver in experimental mode', async ({ runInlineTest }) => {
// We only support experimental esm mode on Node 16+
test.skip(parseInt(process.version.slice(1), 10) < 16);
const result = await runInlineTest({
'package.json': JSON.stringify({ type: 'module' }),
'playwright.config.ts': `
export default {
projects: [{name: 'foo'}],
};
`,
'tsconfig.json': `{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["esnext", "dom", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"util/*": ["./foo/bar/util/*"],
},
},
}`,
'a.test.ts': `
import { foo } from 'util/b.ts';
const { test } = pwt;
test('check project name', ({}, testInfo) => {
expect(testInfo.project.name).toBe(foo);
});
`,
'foo/bar/util/b.ts': `
export const foo: string = 'foo';
`,
}, {}, {
PW_EXPERIMENTAL_TS_ESM: true
});
expect(result.exitCode).toBe(0);
});

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { test, expect } from './playwright-test-fixtures'; import { test, expect, countTimes, stripAnsi } from './playwright-test-fixtures';
test('test.describe.parallel should throw inside test.describe.serial', async ({ runInlineTest }) => { test('test.describe.parallel should throw inside test.describe.serial', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
@ -176,3 +176,49 @@ test('project.fullyParallel should work', async ({ runInlineTest }) => {
expect(result.output).toContain('%% worker=1'); expect(result.output).toContain('%% worker=1');
expect(result.output).toContain('%% worker=2'); expect(result.output).toContain('%% worker=2');
}); });
test('parallel mode should minimize running beforeAll/afterAll hooks', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test.describe.configure({ mode: 'parallel' });
test.beforeAll(() => {
console.log('\\n%%beforeAll');
});
test.afterAll(() => {
console.log('\\n%%afterAll');
});
test('test1', () => {});
test('test2', () => {});
test('test3', () => {});
test('test4', () => {});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(4);
expect(countTimes(stripAnsi(result.output), '%%beforeAll')).toBe(1);
expect(countTimes(stripAnsi(result.output), '%%afterAll')).toBe(1);
});
test('parallel mode should minimize running beforeAll/afterAll hooks 2', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test.describe.configure({ mode: 'parallel' });
test.beforeAll(() => {
console.log('\\n%%beforeAll');
});
test.afterAll(() => {
console.log('\\n%%afterAll');
});
test('test1', () => {});
test('test2', () => {});
test('test3', () => {});
test('test4', () => {});
`,
}, { workers: 2 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(4);
expect(countTimes(stripAnsi(result.output), '%%beforeAll')).toBe(2);
expect(countTimes(stripAnsi(result.output), '%%afterAll')).toBe(2);
});

View file

@ -1,737 +0,0 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mimeTypeToComparator } from 'playwright-core/lib/utils/comparators';
import * as fs from 'fs';
import { PNG } from 'pngjs';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { test, expect, stripAnsi, createImage, paintBlackPixels } from './playwright-test-fixtures';
const pngComparator = mimeTypeToComparator['image/png'];
test.describe.configure({ mode: 'parallel' });
const IMG_WIDTH = 1280;
const IMG_HEIGHT = 720;
const whiteImage = createImage(IMG_WIDTH, IMG_HEIGHT, 255, 255, 255);
const redImage = createImage(IMG_WIDTH, IMG_HEIGHT, 255, 0, 0);
const greenImage = createImage(IMG_WIDTH, IMG_HEIGHT, 0, 255, 0);
const blueImage = createImage(IMG_WIDTH, IMG_HEIGHT, 0, 0, 255);
const files = {
'helper.ts': `
export const test = pwt.test.extend({
auto: [ async ({}, run, testInfo) => {
testInfo.snapshotSuffix = '';
await run();
}, { auto: true } ]
});
`
};
test('should fail to screenshot a page with infinite animation', async ({ runInlineTest }, testInfo) => {
const infiniteAnimationURL = pathToFileURL(path.join(__dirname, '../assets/rotate-z.html'));
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await page.goto('${infiniteAnimationURL}');
await expect(page).toHaveScreenshot({ timeout: 2000 });
});
`
});
expect(result.exitCode).toBe(1);
expect(stripAnsi(result.output)).toContain(`Timeout 2000ms exceeded while generating screenshot because page kept changing`);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-actual.png'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-expected.png'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-diff.png'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('a.spec.js-snapshots', 'is-a-test-1.png'))).toBe(false);
});
test('should not fail when racing with navigation', async ({ runInlineTest }, testInfo) => {
const infiniteAnimationURL = pathToFileURL(path.join(__dirname, '../assets/rotate-z.html'));
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': createImage(10, 10, 255, 0, 0),
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await Promise.all([
page.goto('${infiniteAnimationURL}'),
expect(page).toHaveScreenshot({
name: 'snapshot.png',
animations: "disabled",
clip: { x: 0, y: 0, width: 10, height: 10 },
}),
]);
});
`
});
expect(result.exitCode).toBe(0);
});
test('should successfully screenshot a page with infinite animation with disableAnimation: true', async ({ runInlineTest }, testInfo) => {
const infiniteAnimationURL = pathToFileURL(path.join(__dirname, '../assets/rotate-z.html'));
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await page.goto('${infiniteAnimationURL}');
await expect(page).toHaveScreenshot({
animations: "disabled",
});
});
`
}, { 'update-snapshots': true });
expect(result.exitCode).toBe(0);
expect(fs.existsSync(testInfo.outputPath('a.spec.js-snapshots', 'is-a-test-1.png'))).toBe(true);
});
test('should support clip option for page', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': createImage(50, 50, 255, 255, 255),
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot({
name: 'snapshot.png',
clip: { x: 0, y: 0, width: 50, height: 50, },
});
});
`
});
expect(result.exitCode).toBe(0);
});
test('should support omitBackground option for locator', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await page.evaluate(() => {
document.body.style.setProperty('width', '100px');
document.body.style.setProperty('height', '100px');
});
await expect(page.locator('body')).toHaveScreenshot({
name: 'snapshot.png',
omitBackground: true,
});
});
`
}, { 'update-snapshots': true });
expect(result.exitCode).toBe(0);
const snapshotPath = testInfo.outputPath('a.spec.js-snapshots', 'snapshot.png');
expect(fs.existsSync(snapshotPath)).toBe(true);
const png = PNG.sync.read(fs.readFileSync(snapshotPath));
expect.soft(png.width, 'image width must be 100').toBe(100);
expect.soft(png.height, 'image height must be 100').toBe(100);
expect.soft(png.data[0], 'image R must be 0').toBe(0);
expect.soft(png.data[1], 'image G must be 0').toBe(0);
expect.soft(png.data[2], 'image B must be 0').toBe(0);
expect.soft(png.data[3], 'image A must be 0').toBe(0);
});
test('should fail to screenshot an element with infinite animation', async ({ runInlineTest }, testInfo) => {
const infiniteAnimationURL = pathToFileURL(path.join(__dirname, '../assets/rotate-z.html'));
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await page.goto('${infiniteAnimationURL}');
await expect(page.locator('body')).toHaveScreenshot({ timeout: 2000 });
});
`
});
expect(result.exitCode).toBe(1);
expect(stripAnsi(result.output)).toContain(`Timeout 2000ms exceeded while generating screenshot because element kept changing`);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-actual.png'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-expected.png'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-diff.png'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('a.spec.js-snapshots', 'is-a-test-1.png'))).toBe(false);
});
test('should fail to screenshot an element that keeps moving', async ({ runInlineTest }, testInfo) => {
const infiniteAnimationURL = pathToFileURL(path.join(__dirname, '../assets/rotate-z.html'));
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await page.goto('${infiniteAnimationURL}');
await expect(page.locator('div')).toHaveScreenshot({ timeout: 2000 });
});
`
});
expect(result.exitCode).toBe(1);
expect(stripAnsi(result.output)).toContain(`Timeout 2000ms exceeded`);
expect(stripAnsi(result.output)).toContain(`element is not stable - waiting`);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-actual.png'))).toBe(false);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-expected.png'))).toBe(false);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-diff.png'))).toBe(false);
expect(fs.existsSync(testInfo.outputPath('a.spec.js-snapshots', 'is-a-test-1.png'))).toBe(false);
});
test('should generate default name', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot();
});
`
});
expect(result.exitCode).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-is-a-test', 'is-a-test-1-actual.png'))).toBe(true);
expect(fs.existsSync(testInfo.outputPath('a.spec.js-snapshots', 'is-a-test-1.png'))).toBe(true);
});
test('should compile with different option combinations', async ({ runTSC }) => {
const result = await runTSC({
'a.spec.ts': `
const { test } = pwt;
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot();
await expect(page.locator('body')).toHaveScreenshot({ threshold: 0.2 });
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.2 });
await expect(page).toHaveScreenshot({
threshold: 0.2,
maxDiffPixels: 10,
maxDiffPixelRatio: 0.2,
animations: "disabled",
omitBackground: true,
timeout: 1000,
});
});
`
});
expect(result.exitCode).toBe(0);
});
test('should fail when screenshot is different size', async ({ runInlineTest }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': createImage(22, 33),
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', { timeout: 2000 });
});
`
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain('Expected an image 22px by 33px, received 1280px by 720px.');
});
test('should fail when screenshot is different pixels', async ({ runInlineTest }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': paintBlackPixels(whiteImage, 12345),
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', { timeout: 2000 });
});
`
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain('Screenshot comparison failed');
expect(result.output).toContain('12345 pixels');
expect(result.output).not.toContain('Call log');
expect(result.output).toContain('ratio 0.02');
expect(result.output).toContain('Expected:');
expect(result.output).toContain('Received:');
});
test('doesn\'t create comparison artifacts in an output folder for passed negated snapshot matcher', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': blueImage,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).not.toHaveScreenshot('snapshot.png');
});
`
});
expect(result.exitCode).toBe(0);
const outputText = stripAnsi(result.output);
const expectedSnapshotArtifactPath = testInfo.outputPath('test-results', 'a-is-a-test', 'snapshot-expected.png');
const actualSnapshotArtifactPath = testInfo.outputPath('test-results', 'a-is-a-test', 'snapshot-actual.png');
expect(outputText).not.toContain(`Expected: ${expectedSnapshotArtifactPath}`);
expect(outputText).not.toContain(`Received: ${actualSnapshotArtifactPath}`);
expect(fs.existsSync(expectedSnapshotArtifactPath)).toBe(false);
expect(fs.existsSync(actualSnapshotArtifactPath)).toBe(false);
});
test('should fail on same snapshots with negate matcher', async ({ runInlineTest }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': whiteImage,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).not.toHaveScreenshot('snapshot.png', { timeout: 2000 });
});
`
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain('Screenshot comparison failed:');
expect(result.output).toContain('Expected result should be different from the actual one.');
});
test('should write missing expectations locally twice and continue', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png');
await expect(page).toHaveScreenshot('snapshot2.png');
console.log('Here we are!');
});
`
});
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const snapshot1OutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.png');
expect(result.output).toContain(`Error: ${snapshot1OutputPath} is missing in snapshots, writing actual`);
expect(pngComparator(fs.readFileSync(snapshot1OutputPath), whiteImage)).toBe(null);
const snapshot2OutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot2.png');
expect(result.output).toContain(`Error: ${snapshot2OutputPath} is missing in snapshots, writing actual`);
expect(pngComparator(fs.readFileSync(snapshot2OutputPath), whiteImage)).toBe(null);
expect(result.output).toContain('Here we are!');
const stackLines = stripAnsi(result.output).split('\n').filter(line => line.includes(' at ')).filter(line => !line.includes(testInfo.outputPath()));
expect(result.output).toContain('a.spec.js:8');
expect(stackLines.length).toBe(0);
});
test('shouldn\'t write missing expectations locally for negated matcher', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).not.toHaveScreenshot('snapshot.png');
});
`
});
expect(result.exitCode).toBe(1);
const snapshotOutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.png');
expect(result.output).toContain(`${snapshotOutputPath} is missing in snapshots, matchers using ".not" won\'t write them automatically.`);
expect(fs.existsSync(snapshotOutputPath)).toBe(false);
});
test('should update snapshot with the update-snapshots flag', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': blueImage,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png');
});
`
}, { 'update-snapshots': true });
expect(result.exitCode).toBe(0);
const snapshotOutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.png');
expect(result.output).toContain(`${snapshotOutputPath} is re-generated, writing actual.`);
expect(pngComparator(fs.readFileSync(snapshotOutputPath), whiteImage)).toBe(null);
});
test('shouldn\'t update snapshot with the update-snapshots flag for negated matcher', async ({ runInlineTest }, testInfo) => {
const EXPECTED_SNAPSHOT = blueImage;
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).not.toHaveScreenshot('snapshot.png');
});
`
}, { 'update-snapshots': true });
expect(result.exitCode).toBe(0);
const snapshotOutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.png');
expect(fs.readFileSync(snapshotOutputPath).equals(EXPECTED_SNAPSHOT)).toBe(true);
});
test('should silently write missing expectations locally with the update-snapshots flag', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png');
});
`
}, { 'update-snapshots': true });
expect(result.exitCode).toBe(0);
const snapshotOutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.png');
expect(result.output).toContain(`${snapshotOutputPath} is missing in snapshots, writing actual`);
const data = fs.readFileSync(snapshotOutputPath);
expect(pngComparator(data, whiteImage)).toBe(null);
});
test('should not write missing expectations locally with the update-snapshots flag for negated matcher', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).not.toHaveScreenshot('snapshot.png');
});
`
}, { 'update-snapshots': true });
expect(result.exitCode).toBe(1);
const snapshotOutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.png');
expect(result.output).toContain(`${snapshotOutputPath} is missing in snapshots, matchers using ".not" won\'t write them automatically.`);
expect(fs.existsSync(snapshotOutputPath)).toBe(false);
});
test('should match multiple snapshots', async ({ runInlineTest }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/red.png': redImage,
'a.spec.js-snapshots/green.png': greenImage,
'a.spec.js-snapshots/blue.png': blueImage,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await Promise.all([
page.evaluate(() => document.documentElement.style.setProperty('background', '#f00')),
expect(page).toHaveScreenshot('red.png'),
]);
await Promise.all([
page.evaluate(() => document.documentElement.style.setProperty('background', '#0f0')),
expect(page).toHaveScreenshot('green.png'),
]);
await Promise.all([
page.evaluate(() => document.documentElement.style.setProperty('background', '#00f')),
expect(page).toHaveScreenshot('blue.png'),
]);
});
`
});
expect(result.exitCode).toBe(0);
});
test('should use provided name', async ({ runInlineTest }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/provided.png': whiteImage,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('provided.png');
});
`
});
expect(result.exitCode).toBe(0);
});
test('should use provided name via options', async ({ runInlineTest }) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/provided.png': whiteImage,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot({ name: 'provided.png' });
});
`
});
expect(result.exitCode).toBe(0);
});
test('should respect maxDiffPixels option', async ({ runInlineTest }) => {
const BAD_PIXELS = 120;
const EXPECTED_SNAPSHOT = paintBlackPixels(whiteImage, BAD_PIXELS);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', { timeout: 2000 });
});
`
})).exitCode, 'make sure default comparison fails').toBe(1);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', {
maxDiffPixels: ${BAD_PIXELS}
});
});
`
})).exitCode, 'make sure maxDiffPixels option is respected').toBe(0);
expect((await runInlineTest({
...files,
'playwright.config.ts': `
module.exports = { projects: [
{ expect: { toHaveScreenshot: { maxDiffPixels: ${BAD_PIXELS} } } },
]};
`,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png');
});
`
})).exitCode, 'make sure maxDiffPixels option in project config is respected').toBe(0);
});
test('should satisfy both maxDiffPixelRatio and maxDiffPixels', async ({ runInlineTest }) => {
const BAD_RATIO = 0.25;
const BAD_COUNT = Math.floor(IMG_WIDTH * IMG_HEIGHT * BAD_RATIO);
const EXPECTED_SNAPSHOT = paintBlackPixels(whiteImage, BAD_COUNT);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', { timeout: 2000 });
});
`
})).exitCode, 'make sure default comparison fails').toBe(1);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', {
maxDiffPixels: ${Math.floor(BAD_COUNT / 2)},
maxDiffPixelRatio: ${BAD_RATIO},
timeout: 2000,
});
});
`
})).exitCode, 'make sure it fails when maxDiffPixels < actualBadPixels < maxDiffPixelRatio').toBe(1);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', {
maxDiffPixels: ${BAD_COUNT},
maxDiffPixelRatio: ${BAD_RATIO / 2},
timeout: 2000,
});
});
`
})).exitCode, 'make sure it fails when maxDiffPixelRatio < actualBadPixels < maxDiffPixels').toBe(1);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', {
maxDiffPixels: ${BAD_COUNT},
maxDiffPixelRatio: ${BAD_RATIO},
});
});
`
})).exitCode, 'make sure it passes when actualBadPixels < maxDiffPixelRatio && actualBadPixels < maxDiffPixels').toBe(0);
});
test('should respect maxDiffPixelRatio option', async ({ runInlineTest }) => {
const BAD_RATIO = 0.25;
const BAD_PIXELS = IMG_WIDTH * IMG_HEIGHT * BAD_RATIO;
const EXPECTED_SNAPSHOT = paintBlackPixels(whiteImage, BAD_PIXELS);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', { timeout: 2000 });
});
`
})).exitCode, 'make sure default comparison fails').toBe(1);
expect((await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', {
maxDiffPixelRatio: ${BAD_RATIO}
});
});
`
})).exitCode, 'make sure maxDiffPixelRatio option is respected').toBe(0);
expect((await runInlineTest({
...files,
'playwright.config.ts': `
module.exports = { projects: [
{ expect: { toHaveScreenshot: { maxDiffPixelRatio: ${BAD_RATIO} } } },
]};
`,
'a.spec.js-snapshots/snapshot.png': EXPECTED_SNAPSHOT,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png');
});
`
})).exitCode, 'make sure maxDiffPixels option in project config is respected').toBe(0);
});
test('should throw for invalid maxDiffPixels values', async ({ runInlineTest }) => {
expect((await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot({
maxDiffPixels: -1,
});
});
`
})).exitCode).toBe(1);
});
test('should throw for invalid maxDiffPixelRatio values', async ({ runInlineTest }) => {
expect((await runInlineTest({
...files,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot({
maxDiffPixelRatio: 12,
});
});
`
})).exitCode).toBe(1);
});
test('should attach expected/actual and no diff when sizes are different', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'a.spec.js-snapshots/snapshot.png': createImage(2, 2),
'a.spec.js': `
const { test } = require('./helper');
test.afterEach(async ({}, testInfo) => {
console.log('## ' + JSON.stringify(testInfo.attachments));
});
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png', { timeout: 2000 });
});
`
});
expect(result.exitCode).toBe(1);
const outputText = stripAnsi(result.output);
expect(outputText).toContain('Expected an image 2px by 2px, received 1280px by 720px.');
const attachments = outputText.split('\n').filter(l => l.startsWith('## ')).map(l => l.substring(3)).map(l => JSON.parse(l))[0];
for (const attachment of attachments)
attachment.path = attachment.path.replace(/\\/g, '/').replace(/.*test-results\//, '');
expect(attachments).toEqual([
{
name: 'expected',
contentType: 'image/png',
path: 'a-is-a-test/snapshot-expected.png'
},
{
name: 'actual',
contentType: 'image/png',
path: 'a-is-a-test/snapshot-actual.png'
},
]);
});
test('should fail with missing expectations and retries', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'playwright.config.ts': `
module.exports = { retries: 1 };
`,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png');
});
`
});
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const snapshotOutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.png');
expect(result.output).toContain(`${snapshotOutputPath} is missing in snapshots, writing actual`);
const data = fs.readFileSync(snapshotOutputPath);
expect(pngComparator(data, whiteImage)).toBe(null);
});
test('should update expectations with retries', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...files,
'playwright.config.ts': `
module.exports = { retries: 1 };
`,
'a.spec.js': `
const { test } = require('./helper');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png');
});
`
}, { 'update-snapshots': true });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const snapshotOutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.png');
expect(result.output).toContain(`${snapshotOutputPath} is missing in snapshots, writing actual`);
const data = fs.readFileSync(snapshotOutputPath);
expect(pngComparator(data, whiteImage)).toBe(null);
});

View file

@ -67,6 +67,13 @@ function build {
echo "Unsupported RUN_DRIVER ${RUN_DRIVER}" echo "Unsupported RUN_DRIVER ${RUN_DRIVER}"
exit 1 exit 1
fi fi
# NPM install does intentionally set the modification date back to 1985 for all the files. This confuses language binding
# update mechanisms, which expect the modification date to be recent to decide which file to override. See:
# - https://github.com/npm/npm/issues/20439#issuecomment-385121133
# - https://github.com/microsoft/playwright-dotnet/issues/2069
find . -type f -exec touch {} +
zip -q -r ../playwright-${PACKAGE_VERSION}-${SUFFIX}.zip . zip -q -r ../playwright-${PACKAGE_VERSION}-${SUFFIX}.zip .
} }

View file

@ -448,7 +448,11 @@ Documentation.Type = class {
* @return {Documentation.Type} * @return {Documentation.Type}
*/ */
static fromParsedType(parsedType, inUnion = false) { static fromParsedType(parsedType, inUnion = false) {
if (!inUnion && parsedType.union) { if (!inUnion && !parsedType.unionName && isStringUnion(parsedType) ) {
throw new Error('Enum must have a name:\n' + JSON.stringify(parsedType, null, 2));
}
if (!inUnion && (parsedType.union || parsedType.unionName)) {
const type = new Documentation.Type(parsedType.unionName || ''); const type = new Documentation.Type(parsedType.unionName || '');
type.union = []; type.union = [];
for (let t = parsedType; t; t = t.union) { for (let t = parsedType; t; t = t.union) {
@ -568,8 +572,6 @@ Documentation.Type = class {
* @returns {boolean} * @returns {boolean}
*/ */
function isStringUnion(type) { function isStringUnion(type) {
if (!type.union)
return false;
while (type) { while (type) {
if (!type.name.startsWith('"') || !type.name.endsWith('"')) if (!type.name.startsWith('"') || !type.name.endsWith('"'))
return false; return false;

View file

@ -42,7 +42,7 @@ type ExpectSettings = {
* Default timeout for async expect matchers in milliseconds, defaults to 5000ms. * Default timeout for async expect matchers in milliseconds, defaults to 5000ms.
*/ */
timeout?: number; timeout?: number;
toHaveScreenshot?: { toMatchSnapshot?: {
/** An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between pixels in compared images, between zero (strict) and one (lax). Defaults to `0.2`. /** An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between pixels in compared images, between zero (strict) and one (lax). Defaults to `0.2`.
*/ */
threshold?: number, threshold?: number,
@ -55,11 +55,6 @@ type ExpectSettings = {
*/ */
maxDiffPixelRatio?: number, maxDiffPixelRatio?: number,
} }
toMatchSnapshot?: {
/** An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between pixels in compared images, between zero (strict) and one (lax). Defaults to `0.2`.
*/
threshold?: number,
}
}; };
interface TestProject { interface TestProject {