Merge remote-tracking branch 'origin/main' into hello-angular-ct

This commit is contained in:
Younes Jaaidi 2024-03-26 10:58:46 +01:00
commit 10ee5da3ea
No known key found for this signature in database
GPG key ID: 3126C5717BDF3241
74 changed files with 1353 additions and 705 deletions

View file

@ -4,12 +4,10 @@ inputs:
namePrefix:
description: 'Name prefix of the artifacts to download'
required: true
type: string
default: 'blob-report'
path:
description: 'Directory with downloaded artifacts'
required: true
type: string
default: '.'
runs:
using: "composite"

View file

@ -4,16 +4,13 @@ inputs:
blob_prefix:
description: 'Name of the Azure blob storage directory containing blob report'
required: true
type: string
output_dir:
description: 'Output directory where downloaded blobs will be stored'
required: true
type: string
default: 'blob-report'
connection_string:
description: 'Azure connection string'
required: true
type: string
runs:
using: "composite"
steps:

View file

@ -4,29 +4,30 @@ inputs:
report_dir:
description: 'Directory containing blob report'
required: true
type: string
default: 'test-results/blob-report'
job_name:
description: 'Unique job name'
required: true
type: string
default: ''
runs:
using: "composite"
steps:
- name: Integrity check
shell: bash
run: find "${{ inputs.report_dir }}" -name "*.zip" -exec unzip -t {} \;
- name: Upload blob report to GitHub
if: always() && github.event_name == 'pull_request'
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ inputs.job_name }}
path: ${{ inputs.report_dir }}/**
retention-days: 7
- name: Write triggering pull request number in a file
if: always() && github.event_name == 'pull_request'
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
shell: bash
run: echo '${{ github.event.number }}' > pull_request_number.txt;
- name: Upload artifact with the pull request number
if: always() && github.event_name == 'pull_request'
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@v4
with:
name: pull-request-${{ inputs.job_name }}

View file

@ -25,10 +25,10 @@ jobs:
- run: npm run build
- run: npx playwright install-deps
- run: utils/publish_all_packages.sh --release-candidate
if: "github.event.release.prerelease"
if: ${{ github.event.release.prerelease }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: utils/publish_all_packages.sh --release
if: "!github.event.release.prerelease"
if: ${{ !github.event.release.prerelease }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View file

@ -42,7 +42,7 @@ jobs:
- run: npm run etest
if: matrix.os != 'ubuntu-latest'
- run: node tests/config/checkCoverage.js electron
if: always() && matrix.os == 'ubuntu-latest'
if: ${{ !cancelled() && matrix.os == 'ubuntu-latest' }}
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash

View file

@ -58,10 +58,10 @@ jobs:
- run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-*
- run: node tests/config/checkCoverage.js ${{ matrix.browser }}
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -92,10 +92,10 @@ jobs:
PWTEST_CHANNEL: chromium-tip-of-tree
PWTEST_BOT_NAME: "${{ matrix.os }}-chromium-tip-of-tree"
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -145,10 +145,10 @@ jobs:
- run: xvfb-run npm run ttest -- --shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
if: matrix.os == 'ubuntu-latest'
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -172,18 +172,18 @@ jobs:
env:
PWTEST_BOT_NAME: "web-components-html-reporter"
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: packages/html-reporter/blob-report
job_name: "web-components-html-reporter"
- run: npm run test-web
if: always()
if: ${{ !cancelled() }}
env:
PWTEST_BOT_NAME: "web-components-web"
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: packages/web/blob-report
@ -219,7 +219,7 @@ jobs:
run: npm run test -- --workers=1
working-directory: ./playwright-vscode
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: playwright-vscode/blob-report
@ -255,10 +255,10 @@ jobs:
- run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run itest
if: matrix.os == 'ubuntu-latest'
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report

View file

@ -45,10 +45,10 @@ jobs:
- run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-*
- run: node tests/config/checkCoverage.js ${{ matrix.browser }}
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -77,10 +77,10 @@ jobs:
- run: npx playwright install --with-deps ${{ matrix.browser }} chromium
- run: npm run test -- --project=${{ matrix.browser }}-*
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -113,10 +113,10 @@ jobs:
if: matrix.browser != 'firefox'
shell: bash
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -151,7 +151,7 @@ jobs:
- run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run itest
if: matrix.os == 'ubuntu-latest'
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
headed_tests:
@ -176,14 +176,14 @@ jobs:
- run: npm run build
- run: npx playwright install --with-deps ${{ matrix.browser }} chromium
- run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* --headed
if: always() && startsWith(matrix.os, 'ubuntu-')
if: ${{ !cancelled() && startsWith(matrix.os, 'ubuntu-') }}
- run: npm run test -- --project=${{ matrix.browser }}-* --headed
if: always() && !startsWith(matrix.os, 'ubuntu-')
if: ${{ !cancelled() && !startsWith(matrix.os, 'ubuntu-') }}
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -213,10 +213,10 @@ jobs:
env:
PWTEST_MODE: ${{ matrix.mode }}
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -252,10 +252,10 @@ jobs:
PWTEST_TRACE: 1
PWTEST_CHANNEL: ${{ matrix.channel }}
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -280,10 +280,10 @@ jobs:
env:
PWTEST_CHANNEL: chrome
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -309,10 +309,10 @@ jobs:
env:
PWTEST_CHANNEL: chrome
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -337,10 +337,10 @@ jobs:
env:
PWTEST_CHANNEL: chrome
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -371,10 +371,10 @@ jobs:
- run: npm run ctest
if: matrix.os != 'ubuntu-20.04'
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -405,10 +405,10 @@ jobs:
- run: npm run ctest -- --headed
if: matrix.os != 'ubuntu-latest'
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -433,10 +433,10 @@ jobs:
env:
PWTEST_CHANNEL: firefox-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -462,10 +462,10 @@ jobs:
env:
PWTEST_CHANNEL: firefox-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -490,10 +490,10 @@ jobs:
env:
PWTEST_CHANNEL: firefox-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -518,10 +518,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -547,10 +547,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -575,10 +575,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -603,10 +603,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -632,10 +632,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -660,10 +660,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -688,10 +688,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge-dev
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -717,10 +717,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge-dev
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -745,10 +745,10 @@ jobs:
env:
PWTEST_CHANNEL: msedge-dev
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -773,10 +773,10 @@ jobs:
env:
PWTEST_CHANNEL: chrome-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -802,10 +802,10 @@ jobs:
env:
PWTEST_CHANNEL: chrome-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -830,10 +830,10 @@ jobs:
env:
PWTEST_CHANNEL: chrome-beta
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report
@ -873,10 +873,10 @@ jobs:
PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW: 1
- run: node tests/config/checkCoverage.js chromium
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash
- name: Upload blob report
if: always()
if: ${{ !cancelled() }}
uses: ./.github/actions/upload-blob-report
with:
report_dir: blob-report

View file

@ -33,7 +33,7 @@ jobs:
PLAYWRIGHT_SERVICE_OS: ${{ matrix.service-os }}
PLAYWRIGHT_SERVICE_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ github.sha }}
- name: Upload blob report to GitHub
if: always()
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v3
with:
name: all-blob-reports
@ -43,7 +43,7 @@ jobs:
merge_reports:
name: "Merge reports"
needs: [test]
if: always()
if: ${{ !cancelled() }}
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4

View file

@ -37,22 +37,22 @@ jobs:
- run: npx playwright install firefox-asan
if: matrix.os != 'windows-latest'
- run: npm run stest contexts -- --project=chromium
if: always()
if: ${{ !cancelled() }}
- run: npm run stest browsers -- --project=chromium
if: always()
if: ${{ !cancelled() }}
- run: npm run stest frames -- --project=chromium
if: always()
if: ${{ !cancelled() }}
- run: npm run stest contexts -- --project=webkit
if: always()
if: ${{ !cancelled() }}
- run: npm run stest browsers -- --project=webkit
if: always()
if: ${{ !cancelled() }}
- run: npm run stest frames -- --project=webkit
if: always()
if: ${{ !cancelled() }}
- run: npm run stest contexts -- --project=firefox
if: always()
if: ${{ !cancelled() }}
- run: npm run stest browsers -- --project=firefox
if: always()
if: ${{ !cancelled() }}
- run: npm run stest frames -- --project=firefox
if: always()
if: ${{ !cancelled() }}
- run: npm run stest heap -- --project=chromium
if: always()
if: ${{ !cancelled() }}

View file

@ -36,5 +36,5 @@ jobs:
env:
PWTEST_VIDEO: 1
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash

View file

@ -46,5 +46,5 @@ jobs:
Start-Process -FilePath setup.exe -Verb RunAs -Wait
- run: npm run webview2test
- run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json
if: always()
if: ${{ !cancelled() }}
shell: bash

View file

@ -1,6 +1,6 @@
# 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-124.0.6367.8-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-123.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop -->
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-124.0.6367.8-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-124.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop -->
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
@ -10,7 +10,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr
| :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->124.0.6367.8<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->17.4<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->123.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->124.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details.

View file

@ -14,7 +14,7 @@ A few examples of problems this can catch include:
The following examples rely on the [`@axe-core/playwright`](https://npmjs.org/@axe-core/playwright) package which adds support for running the [axe accessibility testing engine](https://www.deque.com/axe/) as part of your Playwright tests.
:::note Disclaimer
:::note[Disclaimer]
Automated accessibility tests can detect some common accessibility problems such as missing or invalid properties. But many accessibility problems can only be discovered through manual testing. We recommend using a combination of automated testing, manual accessibility assessments, and inclusive user testing.
For manual assessments, we recommend [Accessibility Insights for Web](https://accessibilityinsights.io/docs/web/overview/?referrer=playwright-accessibility-testing-js), a free and open source dev tool that walks you through assessing a website for [WCAG 2.1 AA](https://www.w3.org/WAI/WCAG21/quickref/?currentsidebar=%23col_customize&levels=aaa) coverage.

View file

@ -566,7 +566,7 @@ api_request_context.post("https://example.com/api/createBook", data=data)
```csharp
var data = new Dictionary<string, object>() {
{ "firstNam", "John" },
{ "firstName", "John" },
{ "lastName", "Doe" }
};
await request.PostAsync("https://example.com/api/createBook", new() { DataObject = data });

View file

@ -4,7 +4,7 @@
ElementHandle represents an in-page DOM element. ElementHandles can be created with the [`method: Page.querySelector`] method.
:::caution Discouraged
:::warning[Discouraged]
The use of ElementHandle is discouraged, use [Locator] objects and web-first assertions instead.
:::

View file

@ -14,6 +14,72 @@ FormData form = FormData.create()
page.request().post("http://localhost/submit", RequestOptions.create().setForm(form));
```
## method: FormData.add
* since: v1.43
- returns: <[FormData]>
Adds a field to the form. File values can be passed either as `Path` or as `FilePayload`.
Multiple fields with the same name can be added.
```java
import com.microsoft.playwright.options.FormData;
...
FormData form = FormData.create()
// Only name and value are set.
.add("firstName", "John")
// Name and value are set, filename and Content-Type are inferred from the file path.
.add("attachment", Paths.get("pic.jpg"))
// Name, value, filename and Content-Type are set.
.add("attachment", new FilePayload("table.csv", "text/csv", Files.readAllBytes(Paths.get("my-tble.csv"))));
page.request().post("http://localhost/submit", RequestOptions.create().setForm(form));
```
```csharp
var multipart = Context.APIRequest.CreateFormData();
// Only name and value are set.
multipart.Add("firstName", "John");
// Name, value, filename and Content-Type are set.
multipart.Add("attachment", new FilePayload()
{
Name = "pic.jpg",
MimeType = "image/jpeg",
Buffer = File.ReadAllBytes("john.jpg")
});
// Name, value, filename and Content-Type are set.
multipart.Add("attachment", new FilePayload()
{
Name = "table.csv",
MimeType = "text/csv",
Buffer = File.ReadAllBytes("my-tble.csv")
});
await Page.APIRequest.PostAsync("https://localhost/submit", new() { Multipart = multipart });
```
### param: FormData.add.name
* since: v1.43
- `name` <[string]>
Field name.
### param: FormData.add.value
* since: v1.43
- `value` <[string]|[boolean]|[int]|[Path]|[Object]>
- `name` <[string]> File name
- `mimeType` <[string]> File type
- `buffer` <[Buffer]> File content
Field value.
### param: FormData.add.value
* since: v1.43
* langs: csharp
- `value` <[string]|[boolean]|[int]|[Object]>
- `name` <[string]> File name
- `mimeType` <[string]> File type
- `buffer` <[Buffer]> File content
Field value.
## method: FormData.create
* since: v1.18
* langs: java
@ -36,7 +102,7 @@ FormData form = FormData.create()
// Name and value are set, filename and Content-Type are inferred from the file path.
.set("profilePicture1", Paths.get("john.jpg"))
// Name, value, filename and Content-Type are set.
.set("profilePicture2", new FilePayload("john.jpg", "image/jpeg", Files.readAllBytes(Paths.get("john.jpg"))));
.set("profilePicture2", new FilePayload("john.jpg", "image/jpeg", Files.readAllBytes(Paths.get("john.jpg"))))
.set("age", 30);
page.request().post("http://localhost/submit", RequestOptions.create().setForm(form));
```
@ -52,6 +118,7 @@ multipart.Set("profilePicture", new FilePayload()
MimeType = "image/jpeg",
Buffer = File.ReadAllBytes("john.jpg")
});
multipart.Set("age", 30);
await Page.APIRequest.PostAsync("https://localhost/submit", new() { Multipart = multipart });
```

View file

@ -74,57 +74,11 @@ await page.FrameLocator(".result-frame").First.getByRole(AriaRole.Button).ClickA
**Converting Locator to FrameLocator**
If you have a [Locator] object pointing to an `iframe` it can be converted to [FrameLocator] using [`method: Locator.enterFrame`].
If you have a [Locator] object pointing to an `iframe` it can be converted to [FrameLocator] using [`method: Locator.contentFrame`].
**Converting FrameLocator to Locator**
If you have a [FrameLocator] object it can be converted to [Locator] pointing to the same `iframe` using [`method: FrameLocator.exitFrame`].
## method: FrameLocator.exitFrame
* since: v1.43
- returns: <[Locator]>
Returns a [Locator] object pointing to the same `iframe` as this frame locator.
Useful when you have a [FrameLocator] object obtained somewhere, and later on would like to interact with the `iframe` element.
**Usage**
```js
const frameLocator = page.frameLocator('iframe[name="embedded"]');
// ...
const locator = frameLocator.exitFrame();
await expect(locator).toBeVisible();
```
```java
FrameLocator frameLocator = page.frameLocator("iframe[name=\"embedded\"]");
// ...
Locator locator = frameLocator.exitFrame();
assertThat(locator).isVisible();
```
```python async
frame_locator = page.frame_locator("iframe[name=\"embedded\"]")
# ...
locator = frame_locator.exit_frame
await expect(locator).to_be_visible()
```
```python sync
frame_locator = page.frame_locator("iframe[name=\"embedded\"]")
# ...
locator = frame_locator.exit_frame
expect(locator).to_be_visible()
```
```csharp
var frameLocator = Page.FrameLocator("iframe[name=\"embedded\"]");
// ...
var locator = frameLocator.ExitFrame;
await Expect(locator).ToBeVisibleAsync();
```
If you have a [FrameLocator] object it can be converted to [Locator] pointing to the same `iframe` using [`method: FrameLocator.owner`].
## method: FrameLocator.first
@ -248,3 +202,51 @@ Returns locator to the n-th matching frame. It's zero based, `nth(0)` selects th
### param: FrameLocator.nth.index
* since: v1.17
- `index` <[int]>
## method: FrameLocator.owner
* since: v1.43
- returns: <[Locator]>
Returns a [Locator] object pointing to the same `iframe` as this frame locator.
Useful when you have a [FrameLocator] object obtained somewhere, and later on would like to interact with the `iframe` element.
For a reverse operation, use [`method: Locator.contentFrame`].
**Usage**
```js
const frameLocator = page.frameLocator('iframe[name="embedded"]');
// ...
const locator = frameLocator.owner();
await expect(locator).toBeVisible();
```
```java
FrameLocator frameLocator = page.frameLocator("iframe[name=\"embedded\"]");
// ...
Locator locator = frameLocator.owner();
assertThat(locator).isVisible();
```
```python async
frame_locator = page.frame_locator("iframe[name=\"embedded\"]")
# ...
locator = frame_locator.owner
await expect(locator).to_be_visible()
```
```python sync
frame_locator = page.frame_locator("iframe[name=\"embedded\"]")
# ...
locator = frame_locator.owner
expect(locator).to_be_visible()
```
```csharp
var frameLocator = Page.FrameLocator("iframe[name=\"embedded\"]");
// ...
var locator = frameLocator.Owner;
await Expect(locator).ToBeVisibleAsync();
```

View file

@ -53,7 +53,7 @@ foreach (var li in await page.GetByRole("listitem").AllAsync())
Returns an array of `node.innerText` values for all matching nodes.
:::caution Asserting text
:::warning[Asserting text]
If you need to assert text on the page, prefer [`method: LocatorAssertions.toHaveText`] with [`option: useInnerText`] option to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -85,7 +85,7 @@ var texts = await page.GetByRole(AriaRole.Link).AllInnerTextsAsync();
Returns an array of `node.textContent` values for all matching nodes.
:::caution Asserting text
:::warning[Asserting text]
If you need to assert text on the page, prefer [`method: LocatorAssertions.toHaveText`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -443,7 +443,7 @@ await page.Locator("canvas").ClickAsync(new() {
Returns the number of elements matching the locator.
:::caution Asserting count
:::warning[Asserting count]
If you need to assert the number of elements on the page, prefer [`method: LocatorAssertions.toHaveCount`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -747,7 +747,7 @@ Resolves given locator to the first matching DOM element. If there are no matchi
Resolves given locator to all matching DOM elements. If there are no matching elements, returns an empty list.
## method: Locator.enterFrame
## method: Locator.contentFrame
* since: v1.43
- returns: <[FrameLocator]>
@ -755,40 +755,42 @@ Returns a [FrameLocator] object pointing to the same `iframe` as this locator.
Useful when you have a [Locator] object obtained somewhere, and later on would like to interact with the content inside the frame.
For a reverse operation, use [`method: FrameLocator.owner`].
**Usage**
```js
const locator = page.locator('iframe[name="embedded"]');
// ...
const frameLocator = locator.enterFrame();
const frameLocator = locator.contentFrame();
await frameLocator.getByRole('button').click();
```
```java
Locator locator = page.locator("iframe[name=\"embedded\"]");
// ...
FrameLocator frameLocator = locator.enterFrame();
FrameLocator frameLocator = locator.contentFrame();
frameLocator.getByRole(AriaRole.BUTTON).click();
```
```python async
locator = page.locator("iframe[name=\"embedded\"]")
# ...
frame_locator = locator.enter_frame
frame_locator = locator.content_frame
await frame_locator.get_by_role("button").click()
```
```python sync
locator = page.locator("iframe[name=\"embedded\"]")
# ...
frame_locator = locator.enter_frame
frame_locator = locator.content_frame
frame_locator.get_by_role("button").click()
```
```csharp
var locator = Page.Locator("iframe[name=\"embedded\"]");
// ...
var frameLocator = locator.EnterFrame;
var frameLocator = locator.ContentFrame;
await frameLocator.GetByRole(AriaRole.Button).ClickAsync();
```
@ -1119,7 +1121,7 @@ await locator.ClickAsync();
Returns the matching element's attribute value.
:::caution Asserting attributes
:::warning[Asserting attributes]
If you need to assert an element's attribute, prefer [`method: LocatorAssertions.toHaveAttribute`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -1291,7 +1293,7 @@ Returns the [`element.innerHTML`](https://developer.mozilla.org/en-US/docs/Web/A
Returns the [`element.innerText`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText).
:::caution Asserting text
:::warning[Asserting text]
If you need to assert text on the page, prefer [`method: LocatorAssertions.toHaveText`] with [`option: useInnerText`] option to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -1307,7 +1309,7 @@ If you need to assert text on the page, prefer [`method: LocatorAssertions.toHav
Returns the value for the matching `<input>` or `<textarea>` or `<select>` element.
:::caution Asserting value
:::warning[Asserting value]
If you need to assert input value, prefer [`method: LocatorAssertions.toHaveValue`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -1349,7 +1351,7 @@ Throws elements that are not an input, textarea or a select. However, if the ele
Returns whether the element is checked. Throws if the element is not a checkbox or radio input.
:::caution Asserting checked state
:::warning[Asserting checked state]
If you need to assert that checkbox is checked, prefer [`method: LocatorAssertions.toBeChecked`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -1387,7 +1389,7 @@ var isChecked = await page.GetByRole(AriaRole.Checkbox).IsCheckedAsync();
Returns whether the element is disabled, the opposite of [enabled](../actionability.md#enabled).
:::caution Asserting disabled state
:::warning[Asserting disabled state]
If you need to assert that an element is disabled, prefer [`method: LocatorAssertions.toBeDisabled`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -1425,7 +1427,7 @@ Boolean disabled = await page.GetByRole(AriaRole.Button).IsDisabledAsync();
Returns whether the element is [editable](../actionability.md#editable).
:::caution Asserting editable state
:::warning[Asserting editable state]
If you need to assert that an element is editable, prefer [`method: LocatorAssertions.toBeEditable`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -1463,7 +1465,7 @@ Boolean editable = await page.GetByRole(AriaRole.Textbox).IsEditableAsync();
Returns whether the element is [enabled](../actionability.md#enabled).
:::caution Asserting enabled state
:::warning[Asserting enabled state]
If you need to assert that an element is enabled, prefer [`method: LocatorAssertions.toBeEnabled`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -1501,7 +1503,7 @@ Boolean enabled = await page.GetByRole(AriaRole.Button).IsEnabledAsync();
Returns whether the element is hidden, the opposite of [visible](../actionability.md#visible).
:::caution Asserting visibility
:::warning[Asserting visibility]
If you need to assert that element is hidden, prefer [`method: LocatorAssertions.toBeHidden`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -1538,7 +1540,7 @@ Boolean hidden = await page.GetByRole(AriaRole.Button).IsHiddenAsync();
Returns whether the element is [visible](../actionability.md#visible).
:::caution Asserting visibility
:::warning[Asserting visibility]
If you need to assert that element is visible, prefer [`method: LocatorAssertions.toBeVisible`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::
@ -2322,7 +2324,7 @@ When all steps combined have not finished during the specified [`option: timeout
Returns the [`node.textContent`](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent).
:::caution Asserting text
:::warning[Asserting text]
If you need to assert text on the page, prefer [`method: LocatorAssertions.toHaveText`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
:::

View file

@ -3146,7 +3146,7 @@ return value resolves to `[]`.
## async method: Page.addLocatorHandler
* since: v1.42
:::warning Experimental
:::warning[Experimental]
This method is experimental and its behavior may change in the upcoming releases.
:::

View file

@ -405,7 +405,7 @@ An instance of [FormData] can be created via [`method: APIRequestContext.createF
## js-python-fetch-option-multipart
* langs: js, python
- `multipart` <[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>>
- `multipart` <[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]|Array<[string]|[float]|[boolean]|[ReadStream]|[Object]>>>
- `name` <[string]> File name
- `mimeType` <[string]> File type
- `buffer` <[Buffer]> File content
@ -413,7 +413,8 @@ An instance of [FormData] can be created via [`method: APIRequestContext.createF
Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as
this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data`
unless explicitly provided. File values can be passed either as [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream)
or as file-like object containing file name, mime-type and its content.
or as file-like object containing file name, mime-type and its content. If the value is an array, each element
will be sent as a separate field with the same name.
## csharp-fetch-option-multipart
* langs: csharp

View file

@ -65,7 +65,7 @@ jobs:
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
@ -103,7 +103,7 @@ jobs:
- name: Run your tests
run: pytest --tracing=retain-on-failure
- uses: actions/upload-artifact@v4
if: always()
if: ${{ !cancelled() }}
with:
name: playwright-traces
path: test-results/

View file

@ -52,7 +52,7 @@ var jsHandle = await page.EvaluateHandleAsync("window");
## Element Handles
:::caution Discouraged
:::warning[Discouraged]
The use of [ElementHandle] is discouraged, use [Locator] objects and web-first assertions instead.
:::

View file

@ -264,7 +264,7 @@ Role locators include [buttons, checkboxes, headings, links, lists, tables, and
Note that role locators **do not replace** accessibility audits and conformance tests, but rather give early feedback about the ARIA guidelines.
:::note When to use role locators
:::note[When to use role locators]
We recommend prioritizing role locators to locate elements, as it is the closest way to how users and assistive technology perceive the page.
:::
@ -301,7 +301,7 @@ page.get_by_label("Password").fill("secret")
await page.GetByLabel("Password").FillAsync("secret");
```
:::note When to use label locators
:::note[When to use label locators]
Use this locator when locating form fields.
:::
### Locate by placeholder
@ -340,7 +340,7 @@ await page
.FillAsync("playwright@microsoft.com");
```
:::note When to use placeholder locators
:::note[When to use placeholder locators]
Use this locator when locating form elements that do not have labels but do have placeholder texts.
:::
@ -433,7 +433,7 @@ await Expect(Page
Matching by text always normalizes whitespace, even with exact match. For example, it turns multiple spaces into one, turns line breaks into spaces and ignores leading and trailing whitespace.
:::
:::note When to use text locators
:::note[When to use text locators]
We recommend using text locators to find non interactive elements like `div`, `span`, `p`, etc. For interactive elements like `button`, `a`, `input`, etc. use [role locators](#locate-by-role).
:::
@ -471,7 +471,7 @@ page.get_by_alt_text("playwright logo").click()
await page.GetByAltText("playwright logo").ClickAsync();
```
:::note When to use alt locators
:::note[When to use alt locators]
Use this locator when your element supports alt text such as `img` and `area` elements.
:::
@ -507,7 +507,7 @@ expect(page.get_by_title("Issues count")).to_have_text("25 issues")
await Expect(Page.GetByTitle("Issues count")).toHaveText("25 issues");
```
:::note When to use title locators
:::note[When to use title locators]
Use this locator when your element has the `title` attribute.
:::
@ -543,7 +543,7 @@ page.get_by_test_id("directions").click()
await page.GetByTestId("directions").ClickAsync();
```
:::note When to use testid locators
:::note[When to use testid locators]
You can also use test ids when you choose to use the test id methodology or when you can't locate by [role](#locate-by-role) or [text](#locate-by-text).
:::
@ -693,7 +693,7 @@ await page.Locator("#tsf > div:nth-child(2) > div.A8SBwf > div.RNNXgb > div > di
await page.Locator("//*[@id='tsf']/div[2]/div[1]/div[1]/div/div[2]/input").ClickAsync();
```
:::note When to use this
:::note[When to use this]
CSS and XPath are not recommended as the DOM can often change leading to non resilient tests. Instead, try to come up with a locator that is close to how the user perceives the page such as [role locators](#locate-by-role) or [define an explicit testing contract](#locate-by-test-id) using test ids.
:::

View file

@ -556,10 +556,10 @@ export default defineConfig({
Whether to record trace for each test. Defaults to `'off'`.
* `'off'`: Do not record trace.
* `'on'`: Record trace for each test.
* `'retain-on-failure'`: Record trace for each test, but remove all traces from successful test runs.
* `'on-first-retry'`: Record trace only when retrying a test for the first time.
* `'on-all-retries'`: Record traces only when retrying for all retries.
* `'retain-on-first-failure'`: Record traces only when the test fails for the first time.
* `'on-all-retries'`: Record trace only when retrying a test.
* `'retain-on-failure'`: Record trace for each test. When test run passes, remove the recorded trace.
* `'retain-on-first-failure'`: Record trace for the first run of each test, but not for retires. When test run passes, remove the recorded trace.
For more control, pass an object that specifies `mode` and trace features to enable.

View file

@ -89,7 +89,7 @@ jobs:
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Upload blob report to GitHub Actions Artifacts
if: always()
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
@ -104,7 +104,7 @@ jobs:
...
merge-reports:
# Merge reports after playwright-tests, even if some shards have failed
if: always()
if: ${{ !cancelled() }}
needs: [playwright-tests]
runs-on: ubuntu-latest

View file

@ -19,7 +19,7 @@ export function msToString(ms: number): string {
return '-';
if (ms === 0)
return '0';
return '0ms';
if (ms < 1000)
return ms.toFixed(0) + 'ms';

View file

@ -15,25 +15,25 @@
},
{
"name": "firefox",
"revision": "1445",
"revision": "1447",
"installByDefault": true,
"browserVersion": "123.0"
"browserVersion": "124.0"
},
{
"name": "firefox-asan",
"revision": "1445",
"revision": "1447",
"installByDefault": false,
"browserVersion": "123.0"
"browserVersion": "124.0"
},
{
"name": "firefox-beta",
"revision": "1444",
"revision": "1447",
"installByDefault": false,
"browserVersion": "124.0b3"
"browserVersion": "125.0b3"
},
{
"name": "webkit",
"revision": "1990",
"revision": "1992",
"installByDefault": true,
"revisionOverrides": {
"mac10.14": "1446",

View file

@ -36,7 +36,7 @@ export type FetchOptions = {
headers?: Headers,
data?: string | Buffer | Serializable,
form?: { [key: string]: string|number|boolean; };
multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; };
multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload|Array<string|number|boolean|fs.ReadStream|FilePayload>; };
timeout?: number,
failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean,
@ -188,15 +188,11 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
multipartData = [];
// Convert file-like values to ServerFilePayload structs.
for (const [name, value] of Object.entries(options.multipart)) {
if (isFilePayload(value)) {
const payload = value as FilePayload;
if (!Buffer.isBuffer(payload.buffer))
throw new Error(`Unexpected buffer type of 'data.${name}'`);
multipartData.push({ name, file: filePayloadToJson(payload) });
} else if (value instanceof fs.ReadStream) {
multipartData.push({ name, file: await readStreamToJson(value as fs.ReadStream) });
if (Array.isArray(value)) {
for (const item of value)
multipartData.push(await toFormField(name, item));
} else {
multipartData.push({ name, value: String(value) });
multipartData.push(await toFormField(name, value));
}
}
}
@ -234,6 +230,19 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
}
}
async function toFormField(name: string, value: string|number|boolean|fs.ReadStream|FilePayload): Promise<channels.FormField> {
if (isFilePayload(value)) {
const payload = value as FilePayload;
if (!Buffer.isBuffer(payload.buffer))
throw new Error(`Unexpected buffer type of 'data.${name}'`);
return { name, file: filePayloadToJson(payload) };
} else if (value instanceof fs.ReadStream) {
return { name, file: await readStreamToJson(value as fs.ReadStream) };
} else {
return { name, value: String(value) };
}
}
function isJsonParsable(value: any) {
if (typeof value !== 'string')
return false;

View file

@ -192,7 +192,7 @@ export class Locator implements api.Locator {
return await this._frame.$$(this._selector);
}
enterFrame() {
contentFrame() {
return new FrameLocator(this._frame, this._selector);
}
@ -408,7 +408,7 @@ export class FrameLocator implements api.FrameLocator {
return this.locator(getByRoleSelector(role, options));
}
exitFrame() {
owner() {
return new Locator(this._frame, this._frameSelector);
}

View file

@ -1472,7 +1472,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Firefox HiDPI": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0",
"screen": {
"width": 1792,
"height": 1120
@ -1532,7 +1532,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Firefox": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0",
"screen": {
"width": 1920,
"height": 1080

View file

@ -147,6 +147,11 @@ export module Protocol {
enabled: boolean;
};
export type setRequestInterceptionReturnValue = void;
export type setCacheDisabledParameters = {
browserContextId?: string;
cacheDisabled: boolean;
};
export type setCacheDisabledReturnValue = void;
export type setGeolocationOverrideParameters = {
browserContextId?: string;
geolocation: {
@ -1099,6 +1104,7 @@ export module Protocol {
"Browser.setContextProxy": Browser.setContextProxyParameters;
"Browser.setHTTPCredentials": Browser.setHTTPCredentialsParameters;
"Browser.setRequestInterception": Browser.setRequestInterceptionParameters;
"Browser.setCacheDisabled": Browser.setCacheDisabledParameters;
"Browser.setGeolocationOverride": Browser.setGeolocationOverrideParameters;
"Browser.setUserAgentOverride": Browser.setUserAgentOverrideParameters;
"Browser.setPlatformOverride": Browser.setPlatformOverrideParameters;
@ -1179,6 +1185,7 @@ export module Protocol {
"Browser.setContextProxy": Browser.setContextProxyReturnValue;
"Browser.setHTTPCredentials": Browser.setHTTPCredentialsReturnValue;
"Browser.setRequestInterception": Browser.setRequestInterceptionReturnValue;
"Browser.setCacheDisabled": Browser.setCacheDisabledReturnValue;
"Browser.setGeolocationOverride": Browser.setGeolocationOverrideReturnValue;
"Browser.setUserAgentOverride": Browser.setUserAgentOverrideReturnValue;
"Browser.setPlatformOverride": Browser.setPlatformOverrideReturnValue;

View file

@ -231,12 +231,6 @@ x-pw-tool-item.value > x-div {
mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M4 6h8v1H4V6zm8 3H4v1h8V9z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z'/></svg>");
}
x-pw-tool-item.screenshot > x-div {
/* codicon: device-camera */
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M10.707 3H14.5l.5.5v9l-.5.5h-13l-.5-.5v-9l.5-.5h3.793l.853-.854L6.5 2h3l.354.146.853.854zM2 12h12V4h-3.5l-.354-.146L9.293 3H6.707l-.853.854L5.5 4H2v8zm1.5-7a.5.5 0 1 0 0 1 .5.5 0 0 0 0-1zM8 6a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm0-1a3 3 0 1 0 0 6 3 3 0 0 0 0-6z'/></svg>");
mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path fill-rule='evenodd' clip-rule='evenodd' d='M10.707 3H14.5l.5.5v9l-.5.5h-13l-.5-.5v-9l.5-.5h3.793l.853-.854L6.5 2h3l.354.146.853.854zM2 12h12V4h-3.5l-.354-.146L9.293 3H6.707l-.853.854L5.5 4H2v8zm1.5-7a.5.5 0 1 0 0 1 .5.5 0 0 0 0-1zM8 6a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm0-1a3 3 0 1 0 0 6 3 3 0 0 0 0-6z'/></svg>");
}
x-pw-tool-item.accept > x-div {
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path d='M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/></svg>");
mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path d='M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/></svg>");

View file

@ -761,7 +761,6 @@ class Overlay {
private _assertVisibilityToggle: HTMLElement;
private _assertTextToggle: HTMLElement;
private _assertValuesToggle: HTMLElement;
private _assertScreenshotButton: HTMLElement;
private _offsetX = 0;
private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined;
private _measure: { width: number, height: number } = { width: 0, height: 0 };
@ -808,12 +807,6 @@ class Overlay {
this._assertValuesToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div'));
toolsListElement.appendChild(this._assertValuesToggle);
this._assertScreenshotButton = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
this._assertScreenshotButton.title = 'Assert screenshot';
this._assertScreenshotButton.classList.add('screenshot');
this._assertScreenshotButton.appendChild(this._recorder.injectedScript.document.createElement('x-div'));
toolsListElement.appendChild(this._assertScreenshotButton);
this._updateVisualPosition();
this._refreshListeners();
}
@ -852,15 +845,6 @@ class Overlay {
if (!this._assertValuesToggle.classList.contains('disabled'))
this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue');
}),
addEventListener(this._assertScreenshotButton, 'click', () => {
if (!this._assertScreenshotButton.classList.contains('disabled')) {
this._recorder.delegate.recordAction?.({
name: 'assertScreenshot',
signals: [],
});
this.flashToolSucceeded('assertScreenshot');
}
}),
];
}
@ -883,7 +867,6 @@ class Overlay {
this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue');
this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
this._assertScreenshotButton.classList.toggle('disabled', state.mode !== 'recording');
if (this._offsetX !== state.overlay.offsetX) {
this._offsetX = state.overlay.offsetX;
this._updateVisualPosition();
@ -894,12 +877,8 @@ class Overlay {
this._showOverlay();
}
flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue' | 'assertScreenshot') {
const element = {
'assertingVisibility': this._assertVisibilityToggle,
'assertingValue': this._assertValuesToggle,
'assertScreenshot': this._assertScreenshotButton,
}[tool];
flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') {
const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle;
element.classList.add('succeeded');
setTimeout(() => element.classList.remove('succeeded'), 2000);
}

View file

@ -164,8 +164,6 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
const assertion = action.value ? `ToHaveValueAsync(${quote(action.value)})` : `ToBeEmptyAsync()`;
return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`;
}
case 'assertScreenshot':
return `// AssertScreenshot(await ${subject}.ScreenshotAsync());`;
}
}

View file

@ -152,8 +152,6 @@ export class JavaLanguageGenerator implements LanguageGenerator {
const assertion = action.value ? `hasValue(${quote(action.value)})` : `isEmpty()`;
return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`;
}
case 'assertScreenshot':
return `// assertScreenshot(${subject}.screenshot());`;
}
}

View file

@ -135,8 +135,6 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
const assertion = action.value ? `toHaveValue(${quote(action.value)})` : `toBeEmpty()`;
return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};`;
}
case 'assertScreenshot':
return `${this._isTest ? '' : '// '}await expect(${subject}).toHaveScreenshot();`;
}
}

View file

@ -73,7 +73,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
if (signals.dialog)
formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`);
let code = this._generateActionCall(subject, action);
let code = `${this._awaitPrefix}${this._generateActionCall(subject, action)}`;
if (signals.popup) {
code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as ${signals.popup.popupAlias}_info {
@ -99,7 +99,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
case 'openPage':
throw Error('Not reached');
case 'closePage':
return `${this._awaitPrefix}${subject}.close()`;
return `${subject}.close()`;
case 'click': {
let method = 'click';
if (action.clickCount === 2)
@ -115,37 +115,35 @@ export class PythonLanguageGenerator implements LanguageGenerator {
if (action.position)
options.position = action.position;
const optionsString = formatOptions(options, false);
return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`;
return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`;
}
case 'check':
return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.check()`;
return `${subject}.${this._asLocator(action.selector)}.check()`;
case 'uncheck':
return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.uncheck()`;
return `${subject}.${this._asLocator(action.selector)}.uncheck()`;
case 'fill':
return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`;
return `${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`;
case 'setInputFiles':
return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`;
return `${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`;
case 'press': {
const modifiers = toModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`;
return `${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`;
}
case 'navigate':
return `${this._awaitPrefix}${subject}.goto(${quote(action.url)})`;
return `${subject}.goto(${quote(action.url)})`;
case 'select':
return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`;
return `${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`;
case 'assertText':
return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`;
return `expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`;
case 'assertChecked':
return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${action.checked ? 'to_be_checked()' : 'not_to_be_checked()'}`;
return `expect(${subject}.${this._asLocator(action.selector)}).${action.checked ? 'to_be_checked()' : 'not_to_be_checked()'}`;
case 'assertVisible':
return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).to_be_visible()`;
return `expect(${subject}.${this._asLocator(action.selector)}).to_be_visible()`;
case 'assertValue': {
const assertion = action.value ? `to_have_value(${quote(action.value)})` : `to_be_empty()`;
return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${assertion}`;
return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`;
}
case 'assertScreenshot':
return `# assert_screenshot(${this._awaitPrefix}${subject}.screenshot())`;
}
}

View file

@ -30,7 +30,6 @@ export type ActionName =
'assertText' |
'assertValue' |
'assertChecked' |
'assertScreenshot' |
'assertVisible';
export type ActionBase = {
@ -120,11 +119,7 @@ export type AssertVisibleAction = ActionBase & {
selector: string,
};
export type AssertScreenshotAction = ActionBase & {
name: 'assertScreenshot',
};
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertScreenshotAction;
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction;
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction;
// Signals.

View file

@ -36,6 +36,14 @@ export type TraceViewerServerOptions = {
};
export type TraceViewerRedirectOptions = {
args?: string[];
grep?: string;
grepInvert?: string;
project?: string[];
workers?: number | string;
headed?: boolean;
timeout?: number;
reporter?: string[];
webApp?: string;
isServer?: boolean;
};
@ -102,19 +110,36 @@ export async function startTraceViewerServer(options?: TraceViewerServerOptions)
}
export async function installRootRedirect(server: HttpServer, traceUrls: string[], options: TraceViewerRedirectOptions) {
const params = (traceUrls || []).map(t => `trace=${encodeURIComponent(t)}`);
const params = new URLSearchParams();
for (const traceUrl of traceUrls)
params.append('trace', traceUrl);
if (server.wsGuid())
params.push('ws=' + server.wsGuid());
params.append('ws', server.wsGuid()!);
if (options?.isServer)
params.push('isServer');
params.append('isServer', '');
if (isUnderTest())
params.push('isUnderTest=true');
const searchQuery = params.length ? '?' + params.join('&') : '';
const urlPath = `/trace/${options.webApp || 'index.html'}${searchQuery}`;
params.append('isUnderTest', 'true');
for (const arg of options.args || [])
params.append('arg', arg);
if (options.grep)
params.append('grep', options.grep);
if (options.grepInvert)
params.append('grepInvert', options.grepInvert);
for (const project of options.project || [])
params.append('project', project);
if (options.workers)
params.append('workers', String(options.workers));
if (options.timeout)
params.append('timeout', String(options.timeout));
if (options.headed)
params.append('headed', '');
for (const reporter of options.reporter || [])
params.append('reporter', reporter);
server.routePath('/', (request, response) => {
const urlPath = `/trace/${options.webApp || 'index.html'}?${params.toString()}`;
server.routePath('/', (_, response) => {
response.statusCode = 302;
response.setHeader('Location', urlPath + request.url!.substring(1));
response.setHeader('Location', urlPath);
response.end();
return true;
});

View file

@ -1569,7 +1569,7 @@ export module Protocol {
/**
* The reason the console is being cleared.
*/
export type ClearReason = "console-api"|"main-frame-navigation";
export type ClearReason = "console-api"|"frontend"|"main-frame-navigation";
/**
* Logging channel.
*/
@ -1738,6 +1738,14 @@ export module Protocol {
}
export type clearMessagesReturnValue = {
}
/**
* Control whether calling <code>console.clear()</code> has an effect in Web Inspector. Defaults to true.
*/
export type setConsoleClearAPIEnabledParameters = {
enable: boolean;
}
export type setConsoleClearAPIEnabledReturnValue = {
}
/**
* List of the different message sources that are non-default logging channels.
*/
@ -9388,6 +9396,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
"Console.enable": Console.enableParameters;
"Console.disable": Console.disableParameters;
"Console.clearMessages": Console.clearMessagesParameters;
"Console.setConsoleClearAPIEnabled": Console.setConsoleClearAPIEnabledParameters;
"Console.getLoggingChannels": Console.getLoggingChannelsParameters;
"Console.setLoggingChannelLevel": Console.setLoggingChannelLevelParameters;
"DOM.getDocument": DOM.getDocumentParameters;
@ -9698,6 +9707,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
"Console.enable": Console.enableReturnValue;
"Console.disable": Console.disableReturnValue;
"Console.clearMessages": Console.clearMessagesReturnValue;
"Console.setConsoleClearAPIEnabled": Console.setConsoleClearAPIEnabledReturnValue;
"Console.getLoggingChannels": Console.getLoggingChannelsReturnValue;
"Console.setLoggingChannelLevel": Console.setLoggingChannelLevelReturnValue;
"DOM.getDocument": DOM.getDocumentReturnValue;

View file

@ -11238,6 +11238,27 @@ export interface Locator {
trial?: boolean;
}): Promise<void>;
/**
* Returns a {@link FrameLocator} object pointing to the same `iframe` as this locator.
*
* Useful when you have a {@link Locator} object obtained somewhere, and later on would like to interact with the
* content inside the frame.
*
* For a reverse operation, use
* [frameLocator.owner()](https://playwright.dev/docs/api/class-framelocator#frame-locator-owner).
*
* **Usage**
*
* ```js
* const locator = page.locator('iframe[name="embedded"]');
* // ...
* const frameLocator = locator.contentFrame();
* await frameLocator.getByRole('button').click();
* ```
*
*/
contentFrame(): FrameLocator;
/**
* Returns the number of elements matching the locator.
*
@ -11462,24 +11483,6 @@ export interface Locator {
*/
elementHandles(): Promise<Array<ElementHandle>>;
/**
* Returns a {@link FrameLocator} object pointing to the same `iframe` as this locator.
*
* Useful when you have a {@link Locator} object obtained somewhere, and later on would like to interact with the
* content inside the frame.
*
* **Usage**
*
* ```js
* const locator = page.locator('iframe[name="embedded"]');
* // ...
* const frameLocator = locator.enterFrame();
* await frameLocator.getByRole('button').click();
* ```
*
*/
enterFrame(): FrameLocator;
/**
* Set a value to the input field.
*
@ -15680,7 +15683,8 @@ export interface APIRequestContext {
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
* explicitly provided. File values can be passed either as
* [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file
* name, mime-type and its content.
* name, mime-type and its content. If the value is an array, each element will be sent as a separate field with the
* same name.
*/
multipart?: { [key: string]: string|number|boolean|ReadStream|{
/**
@ -15697,7 +15701,22 @@ export interface APIRequestContext {
* File content
*/
buffer: Buffer;
}; };
}|Array<string|number|boolean|ReadStream|{
/**
* File name
*/
name: string;
/**
* File type
*/
mimeType: string;
/**
* File content
*/
buffer: Buffer;
}>; };
/**
* Query parameters to be sent with the URL.
@ -15814,7 +15833,8 @@ export interface APIRequestContext {
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
* explicitly provided. File values can be passed either as
* [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file
* name, mime-type and its content.
* name, mime-type and its content. If the value is an array, each element will be sent as a separate field with the
* same name.
*/
multipart?: { [key: string]: string|number|boolean|ReadStream|{
/**
@ -15831,7 +15851,22 @@ export interface APIRequestContext {
* File content
*/
buffer: Buffer;
}; };
}|Array<string|number|boolean|ReadStream|{
/**
* File name
*/
name: string;
/**
* File type
*/
mimeType: string;
/**
* File content
*/
buffer: Buffer;
}>; };
/**
* Query parameters to be sent with the URL.
@ -15908,7 +15943,8 @@ export interface APIRequestContext {
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
* explicitly provided. File values can be passed either as
* [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file
* name, mime-type and its content.
* name, mime-type and its content. If the value is an array, each element will be sent as a separate field with the
* same name.
*/
multipart?: { [key: string]: string|number|boolean|ReadStream|{
/**
@ -15925,7 +15961,22 @@ export interface APIRequestContext {
* File content
*/
buffer: Buffer;
}; };
}|Array<string|number|boolean|ReadStream|{
/**
* File name
*/
name: string;
/**
* File type
*/
mimeType: string;
/**
* File content
*/
buffer: Buffer;
}>; };
/**
* Query parameters to be sent with the URL.
@ -15988,7 +16039,8 @@ export interface APIRequestContext {
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
* explicitly provided. File values can be passed either as
* [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file
* name, mime-type and its content.
* name, mime-type and its content. If the value is an array, each element will be sent as a separate field with the
* same name.
*/
multipart?: { [key: string]: string|number|boolean|ReadStream|{
/**
@ -16005,7 +16057,22 @@ export interface APIRequestContext {
* File content
*/
buffer: Buffer;
}; };
}|Array<string|number|boolean|ReadStream|{
/**
* File name
*/
name: string;
/**
* File type
*/
mimeType: string;
/**
* File content
*/
buffer: Buffer;
}>; };
/**
* Query parameters to be sent with the URL.
@ -16068,7 +16135,8 @@ export interface APIRequestContext {
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
* explicitly provided. File values can be passed either as
* [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file
* name, mime-type and its content.
* name, mime-type and its content. If the value is an array, each element will be sent as a separate field with the
* same name.
*/
multipart?: { [key: string]: string|number|boolean|ReadStream|{
/**
@ -16085,7 +16153,22 @@ export interface APIRequestContext {
* File content
*/
buffer: Buffer;
}; };
}|Array<string|number|boolean|ReadStream|{
/**
* File name
*/
name: string;
/**
* File type
*/
mimeType: string;
/**
* File content
*/
buffer: Buffer;
}>; };
/**
* Query parameters to be sent with the URL.
@ -16199,7 +16282,8 @@ export interface APIRequestContext {
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
* explicitly provided. File values can be passed either as
* [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file
* name, mime-type and its content.
* name, mime-type and its content. If the value is an array, each element will be sent as a separate field with the
* same name.
*/
multipart?: { [key: string]: string|number|boolean|ReadStream|{
/**
@ -16216,7 +16300,22 @@ export interface APIRequestContext {
* File content
*/
buffer: Buffer;
}; };
}|Array<string|number|boolean|ReadStream|{
/**
* File name
*/
name: string;
/**
* File type
*/
mimeType: string;
/**
* File content
*/
buffer: Buffer;
}>; };
/**
* Query parameters to be sent with the URL.
@ -16279,7 +16378,8 @@ export interface APIRequestContext {
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
* explicitly provided. File values can be passed either as
* [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file
* name, mime-type and its content.
* name, mime-type and its content. If the value is an array, each element will be sent as a separate field with the
* same name.
*/
multipart?: { [key: string]: string|number|boolean|ReadStream|{
/**
@ -16296,7 +16396,22 @@ export interface APIRequestContext {
* File content
*/
buffer: Buffer;
}; };
}|Array<string|number|boolean|ReadStream|{
/**
* File name
*/
name: string;
/**
* File type
*/
mimeType: string;
/**
* File content
*/
buffer: Buffer;
}>; };
/**
* Query parameters to be sent with the URL.
@ -17783,32 +17898,14 @@ export interface FileChooser {
* **Converting Locator to FrameLocator**
*
* If you have a {@link Locator} object pointing to an `iframe` it can be converted to {@link FrameLocator} using
* [locator.enterFrame()](https://playwright.dev/docs/api/class-locator#locator-enter-frame).
* [locator.contentFrame()](https://playwright.dev/docs/api/class-locator#locator-content-frame).
*
* **Converting FrameLocator to Locator**
*
* If you have a {@link FrameLocator} object it can be converted to {@link Locator} pointing to the same `iframe`
* using [frameLocator.exitFrame()](https://playwright.dev/docs/api/class-framelocator#frame-locator-exit-frame).
* using [frameLocator.owner()](https://playwright.dev/docs/api/class-framelocator#frame-locator-owner).
*/
export interface FrameLocator {
/**
* Returns a {@link Locator} object pointing to the same `iframe` as this frame locator.
*
* Useful when you have a {@link FrameLocator} object obtained somewhere, and later on would like to interact with the
* `iframe` element.
*
* **Usage**
*
* ```js
* const frameLocator = page.frameLocator('iframe[name="embedded"]');
* // ...
* const locator = frameLocator.exitFrame();
* await expect(locator).toBeVisible();
* ```
*
*/
exitFrame(): Locator;
/**
* Returns locator to the first matching frame.
*/
@ -18190,6 +18287,27 @@ export interface FrameLocator {
* @param index
*/
nth(index: number): FrameLocator;
/**
* Returns a {@link Locator} object pointing to the same `iframe` as this frame locator.
*
* Useful when you have a {@link FrameLocator} object obtained somewhere, and later on would like to interact with the
* `iframe` element.
*
* For a reverse operation, use
* [locator.contentFrame()](https://playwright.dev/docs/api/class-locator#locator-content-frame).
*
* **Usage**
*
* ```js
* const frameLocator = page.frameLocator('iframe[name="embedded"]');
* // ...
* const locator = frameLocator.owner();
* await expect(locator).toBeVisible();
* ```
*
*/
owner(): Locator;
}
/**

View file

@ -150,7 +150,7 @@ export type ImportInfo = {
export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, filename: string): { localName: string, info: ImportInfo } {
const importSource = importNode.source.value;
const idPrefix = importSource.replace(/[^\w_\d]/g, '_');
const idPrefix = path.join(filename, '..', importSource).replace(/[^\w_\d]/g, '_');
const result: ImportInfo = {
id: idPrefix,
@ -193,7 +193,4 @@ const artifactExtensions = new Set([
'.ttf',
'.otf',
'.eot',
// Other assets
'.json',
]);
]);

View file

@ -46,15 +46,7 @@ export type SerializedConfig = {
compilationCache?: SerializedCompilationCache;
};
export type TtyParams = {
rows: number | undefined;
columns: number | undefined;
colorDepth: number;
};
export type ProcessInitParams = {
stdoutParams: TtyParams;
stderrParams: TtyParams;
processName: string;
};

View file

@ -14,8 +14,7 @@
* limitations under the License.
*/
import type { WriteStream } from 'tty';
import type { EnvProducedPayload, ProcessInitParams, TtyParams } from './ipc';
import type { EnvProducedPayload, ProcessInitParams } from './ipc';
import { startProfiling, stopProfiling } from 'playwright-core/lib/utils';
import type { TestInfoError } from '../../types/test';
import { serializeError } from '../util';
@ -67,8 +66,6 @@ const startingEnv = { ...process.env };
process.on('message', async (message: any) => {
if (message.method === '__init__') {
const { processParams, runnerParams, runnerScript } = message.params as { processParams: ProcessInitParams, runnerParams: any, runnerScript: string };
setTtyParams(process.stdout, processParams.stdoutParams);
setTtyParams(process.stderr, processParams.stderrParams);
void startProfiling();
const { create } = require(runnerScript);
processRunner = create(runnerParams) as ProcessRunner;
@ -117,40 +114,3 @@ function sendMessageToParent(message: { method: string, params?: any }) {
// Can throw when closing.
}
}
function setTtyParams(stream: WriteStream, params: TtyParams) {
stream.isTTY = true;
if (params.rows)
stream.rows = params.rows;
if (params.columns)
stream.columns = params.columns;
stream.getColorDepth = () => params.colorDepth;
stream.hasColors = ((count = 16) => {
// count is optional and the first argument may actually be env.
if (typeof count !== 'number')
count = 16;
return count <= 2 ** params.colorDepth;
})as any;
// Stubs for the rest of the methods to avoid exceptions in user code.
stream.clearLine = (dir: any, callback?: () => void) => {
callback?.();
return true;
};
stream.clearScreenDown = (callback?: () => void) => {
callback?.();
return true;
};
(stream as any).cursorTo = (x: number, y?: number | (() => void), callback?: () => void) => {
if (callback)
callback();
else if (y instanceof Function)
y();
return true;
};
stream.moveCursor = (dx: number, dy: number, callback?: () => void) => {
callback?.();
return true;
};
stream.getWindowSize = () => [stream.columns, stream.rows];
}

View file

@ -15,7 +15,6 @@
*/
import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface';
import type * as reporterTypes from 'playwright/types/testReporter';
import * as events from './events';
export class TestServerConnection implements TestServerInterface, TestServerInterfaceEvents {
@ -67,7 +66,7 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
this._connectedPromise = new Promise<void>((f, r) => {
this._ws.addEventListener('open', () => {
f();
this._ws.send(JSON.stringify({ method: 'ready' }));
this._ws.send(JSON.stringify({ id: -1, method: 'ready' }));
});
this._ws.addEventListener('error', r);
});
@ -77,6 +76,10 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
});
}
connect() {
return this._connectedPromise;
}
private async _sendMessage(method: string, params?: any): Promise<any> {
const logForTest = (globalThis as any).__logForTest;
logForTest?.({ method, params });
@ -103,81 +106,91 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
this._onListChangedEmitter.fire(params);
else if (method === 'testFilesChanged')
this._onTestFilesChangedEmitter.fire(params);
else if (method === 'loadTraceRequested')
this._onLoadTraceRequestedEmitter.fire(params);
}
async ping(): Promise<void> {
async setSerializer(params: { serializer: string; }): Promise<void> {
await this._sendMessage('setSerializer', params);
}
async ping(params: Parameters<TestServerInterface['ping']>[0]): ReturnType<TestServerInterface['ping']> {
await this._sendMessage('ping');
}
async pingNoReply() {
async pingNoReply(params: Parameters<TestServerInterface['ping']>[0]) {
this._sendMessageNoReply('ping');
}
async watch(params: { fileNames: string[]; }): Promise<void> {
async watch(params: Parameters<TestServerInterface['watch']>[0]): ReturnType<TestServerInterface['watch']> {
await this._sendMessage('watch', params);
}
watchNoReply(params: { fileNames: string[]; }) {
watchNoReply(params: Parameters<TestServerInterface['watch']>[0]) {
this._sendMessageNoReply('watch', params);
}
async open(params: { location: reporterTypes.Location; }): Promise<void> {
async watchTestDir(params: Parameters<TestServerInterface['watchTestDir']>[0]): ReturnType<TestServerInterface['watchTestDir']> {
await this._sendMessage('watchTestDir', params);
}
async open(params: Parameters<TestServerInterface['open']>[0]): ReturnType<TestServerInterface['open']> {
await this._sendMessage('open', params);
}
openNoReply(params: { location: reporterTypes.Location; }) {
openNoReply(params: Parameters<TestServerInterface['open']>[0]) {
this._sendMessageNoReply('open', params);
}
async resizeTerminal(params: { cols: number; rows: number; }): Promise<void> {
async resizeTerminal(params: Parameters<TestServerInterface['resizeTerminal']>[0]): ReturnType<TestServerInterface['resizeTerminal']> {
await this._sendMessage('resizeTerminal', params);
}
resizeTerminalNoReply(params: { cols: number; rows: number; }) {
resizeTerminalNoReply(params: Parameters<TestServerInterface['resizeTerminal']>[0]) {
this._sendMessageNoReply('resizeTerminal', params);
}
async checkBrowsers(): Promise<{ hasBrowsers: boolean; }> {
async checkBrowsers(params: Parameters<TestServerInterface['checkBrowsers']>[0]): ReturnType<TestServerInterface['checkBrowsers']> {
return await this._sendMessage('checkBrowsers');
}
async installBrowsers(): Promise<void> {
async installBrowsers(params: Parameters<TestServerInterface['installBrowsers']>[0]): ReturnType<TestServerInterface['installBrowsers']> {
await this._sendMessage('installBrowsers');
}
async runGlobalSetup(): Promise<'passed' | 'failed' | 'timedout' | 'interrupted'> {
async runGlobalSetup(params: Parameters<TestServerInterface['runGlobalSetup']>[0]): ReturnType<TestServerInterface['runGlobalSetup']> {
return await this._sendMessage('runGlobalSetup');
}
async runGlobalTeardown(): Promise<'passed' | 'failed' | 'timedout' | 'interrupted'> {
async runGlobalTeardown(params: Parameters<TestServerInterface['runGlobalTeardown']>[0]): ReturnType<TestServerInterface['runGlobalTeardown']> {
return await this._sendMessage('runGlobalTeardown');
}
async listFiles(): Promise<{ projects: { name: string; testDir: string; use: { testIdAttribute?: string | undefined; }; files: string[]; }[]; cliEntryPoint?: string | undefined; error?: reporterTypes.TestError | undefined; }> {
return await this._sendMessage('listFiles');
async listFiles(params: Parameters<TestServerInterface['listFiles']>[0]): ReturnType<TestServerInterface['listFiles']> {
return await this._sendMessage('listFiles', params);
}
async listTests(params: { reporter?: string | undefined; fileNames?: string[] | undefined; }): Promise<{ report: any[] }> {
async listTests(params: Parameters<TestServerInterface['listTests']>[0]): ReturnType<TestServerInterface['listTests']> {
return await this._sendMessage('listTests', params);
}
async runTests(params: { reporter?: string | undefined; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }): Promise<{ status: reporterTypes.FullResult['status'] }> {
async runTests(params: Parameters<TestServerInterface['runTests']>[0]): ReturnType<TestServerInterface['runTests']> {
return await this._sendMessage('runTests', params);
}
async findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[] | undefined; }> {
async findRelatedTestFiles(params: Parameters<TestServerInterface['findRelatedTestFiles']>[0]): ReturnType<TestServerInterface['findRelatedTestFiles']> {
return await this._sendMessage('findRelatedTestFiles', params);
}
async stopTests(): Promise<void> {
async stopTests(params: Parameters<TestServerInterface['stopTests']>[0]): ReturnType<TestServerInterface['stopTests']> {
await this._sendMessage('stopTests');
}
stopTestsNoReply() {
stopTestsNoReply(params: Parameters<TestServerInterface['stopTests']>[0]) {
this._sendMessageNoReply('stopTests');
}
async closeGracefully(): Promise<void> {
async closeGracefully(params: Parameters<TestServerInterface['closeGracefully']>[0]): ReturnType<TestServerInterface['closeGracefully']> {
await this._sendMessage('closeGracefully');
}
}

View file

@ -16,69 +16,84 @@
import type * as reporterTypes from '../../types/testReporter';
import type { Event } from './events';
import type { JsonEvent } from './teleReceiver';
export type ReportEntry = JsonEvent;
export interface TestServerInterface {
ping(): Promise<void>;
setSerializer(params: { serializer: string }): Promise<void>;
ping(params: {}): Promise<void>;
watch(params: {
fileNames: string[];
}): Promise<void>;
watchTestDir(params: {}): Promise<void>;
open(params: { location: reporterTypes.Location }): Promise<void>;
resizeTerminal(params: { cols: number, rows: number }): Promise<void>;
checkBrowsers(): Promise<{ hasBrowsers: boolean }>;
checkBrowsers(params: {}): Promise<{ hasBrowsers: boolean }>;
installBrowsers(): Promise<void>;
installBrowsers(params: {}): Promise<void>;
runGlobalSetup(): Promise<reporterTypes.FullResult['status']>;
runGlobalSetup(params: {}): Promise<{
report: ReportEntry[],
status: reporterTypes.FullResult['status']
}>;
runGlobalTeardown(): Promise<reporterTypes.FullResult['status']>;
runGlobalTeardown(params: {}): Promise<{
report: ReportEntry[],
status: reporterTypes.FullResult['status']
}>;
listFiles(): Promise<{
projects: {
name: string;
testDir: string;
use: { testIdAttribute?: string };
files: string[];
}[];
cliEntryPoint?: string;
error?: reporterTypes.TestError;
listFiles(params: {
projects?: string[];
}): Promise<{
report: ReportEntry[];
status: reporterTypes.FullResult['status']
}>;
/**
* Returns list of teleReporter events.
*/
listTests(params: {
reporter?: string;
fileNames?: string[];
}): Promise<{ report: any[] }>;
projects?: string[];
locations?: string[];
}): Promise<{
report: ReportEntry[],
status: reporterTypes.FullResult['status']
}>;
runTests(params: {
reporter?: string;
locations?: string[];
grep?: string;
grepInvert?: string;
testIds?: string[];
headed?: boolean;
oneWorker?: boolean;
workers?: number | string;
timeout?: number,
reporters?: string[],
trace?: 'on' | 'off';
projects?: string[];
reuseContext?: boolean;
connectWsEndpoint?: string;
}): Promise<{ status: reporterTypes.FullResult['status'] }>;
}): Promise<{
status: reporterTypes.FullResult['status'];
}>;
findRelatedTestFiles(params: {
files: string[];
}): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[]; }>;
stopTests(): Promise<void>;
stopTests(params: {}): Promise<void>;
closeGracefully(): Promise<void>;
closeGracefully(params: {}): Promise<void>;
}
export interface TestServerInterfaceEvents {
onClose: Event<void>;
onReport: Event<any>;
onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>;
onListChanged: Event<void>;
@ -87,8 +102,7 @@ export interface TestServerInterfaceEvents {
}
export interface TestServerInterfaceEventEmitters {
dispatchEvent(event: 'close', params: {}): void;
dispatchEvent(event: 'report', params: any): void;
dispatchEvent(event: 'report', params: ReportEntry): void;
dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void;
dispatchEvent(event: 'listChanged', params: {}): void;
dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void;

View file

@ -34,6 +34,7 @@ export { program } from 'playwright-core/lib/cli/program';
import type { ReporterDescription } from '../types/test';
import { prepareErrorStack } from './reporters/base';
import { cacheDir } from './transform/compilationCache';
import * as testServer from './runner/testServer';
function addTestCommand(program: Command) {
const command = program.command('test [test-filter...]');
@ -107,6 +108,7 @@ function addFindRelatedTestFilesCommand(program: Command) {
function addTestServerCommand(program: Command) {
const command = program.command('test-server', { hidden: true });
command.description('start test server');
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`);
command.option('--host <host>', 'Host to start the server on', 'localhost');
command.option('--port <port>', 'Port to start the server on', '0');
command.action(opts => runTestServer(opts));
@ -151,7 +153,28 @@ Examples:
async function runTests(args: string[], opts: { [key: string]: any }) {
await startProfiling();
const config = await loadConfigFromFileRestartIfNeeded(opts.config, overridesFromOptions(opts), opts.deps === false);
const cliOverrides = overridesFromOptions(opts);
if (opts.ui || opts.uiHost || opts.uiPort) {
const status = await testServer.runUIMode(opts.config, {
host: opts.uiHost,
port: opts.uiPort ? +opts.uiPort : undefined,
args,
grep: opts.grep as string | undefined,
grepInvert: opts.grepInvert as string | undefined,
project: opts.project || undefined,
headed: opts.headed,
reporter: Array.isArray(opts.reporter) ? opts.reporter : opts.reporter ? [opts.reporter] : undefined,
workers: cliOverrides.workers,
timeout: cliOverrides.timeout,
});
await stopProfiling('runner');
const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1);
gracefullyProcessExitDoNotHang(exitCode);
return;
}
const config = await loadConfigFromFileRestartIfNeeded(opts.config, cliOverrides, opts.deps === false);
if (!config)
return;
@ -164,9 +187,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
const runner = new Runner(config);
let status: FullResult['status'];
if (opts.ui || opts.uiHost || opts.uiPort)
status = await runner.runUIMode({ host: opts.uiHost, port: opts.uiPort ? +opts.uiPort : undefined });
else if (process.env.PWTEST_WATCH)
if (process.env.PWTEST_WATCH)
status = await runner.watchAllTests();
else
status = await runner.runAllTests();
@ -176,14 +197,9 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
}
async function runTestServer(opts: { [key: string]: any }) {
const config = await loadConfigFromFileRestartIfNeeded(opts.config, overridesFromOptions(opts), opts.deps === false);
if (!config)
return;
config.cliPassWithNoTests = true;
const runner = new Runner(config);
const host = opts.host || 'localhost';
const port = opts.port ? +opts.port : 0;
const status = await runner.runTestServer({ host, port });
const status = await testServer.runTestServer(opts.config, { host, port });
const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1);
gracefullyProcessExitDoNotHang(exitCode);
}

View file

@ -51,7 +51,8 @@ type HtmlReporterOptions = {
host?: string,
port?: number,
attachmentsBaseURL?: string,
_mode?: string;
_mode?: 'test' | 'list';
_isTestServer?: boolean;
};
class HtmlReporter extends EmptyReporter {
@ -67,6 +68,8 @@ class HtmlReporter extends EmptyReporter {
constructor(options: HtmlReporterOptions) {
super();
this._options = options;
if (options._mode === 'test')
process.env.PW_HTML_REPORT = '1';
}
override printsToStdio() {
@ -125,7 +128,7 @@ class HtmlReporter extends EmptyReporter {
if (process.env.CI || !this._buildResult)
return;
const { ok, singleTestId } = this._buildResult;
const shouldOpen = this._open === 'always' || (!ok && this._open === 'on-failure');
const shouldOpen = !this._options._isTestServer && (this._open === 'always' || (!ok && this._open === 'on-failure'));
if (shouldOpen) {
await showHTMLReport(this._outputFolder, this._options.host, this._options.port, singleTestId);
} else if (this._options._mode === 'test') {
@ -213,6 +216,8 @@ class HtmlBuilder {
private _dataZipFile: ZipFile;
private _hasTraces = false;
private _attachmentsBaseURL: string;
private _projectToId: Map<Suite, number> = new Map();
private _lastProjectId = 0;
constructor(config: FullConfig, outputDir: string, attachmentsBaseURL: string) {
this._config = config;
@ -353,7 +358,7 @@ class HtmlBuilder {
path = path.slice(1);
const [file, ...titles] = test.titlePath();
const testIdExpression = `[project=${projectName}]${toPosixPath(file)}\x1e${titles.join('\x1e')} (repeat:${test.repeatEachIndex})`;
const testIdExpression = `[project=${this._projectId(test.parent)}]${toPosixPath(file)}\x1e${titles.join('\x1e')} (repeat:${test.repeatEachIndex})`;
const testId = fileId + '-' + calculateSha1(testIdExpression).slice(0, 20);
const results = test.results.map(r => this._createTestResult(test, r));
@ -392,6 +397,16 @@ class HtmlBuilder {
};
}
private _projectId(suite: Suite): number {
const project = projectSuite(suite);
let id = this._projectToId.get(project);
if (!id) {
id = ++this._lastProjectId;
this._projectToId.set(project, id);
}
return id;
}
private _serializeAttachments(attachments: JsonAttachment[]) {
let lastAttachment: TestAttachment | undefined;
return attachments.map(a => {
@ -629,4 +644,10 @@ function createSnippets(stepsInFile: MultiMap<string, TestStep>) {
}
}
function projectSuite(suite: Suite): Suite {
while (suite.parent?.parent)
suite = suite.parent;
return suite;
}
export default HtmlReporter;

View file

@ -37,7 +37,7 @@ type ReportData = {
};
export async function createMergedReport(config: FullConfigInternal, dir: string, reporterDescriptions: ReporterDescription[], rootDirOverride: string | undefined) {
const reporters = await createReporters(config, 'merge', reporterDescriptions);
const reporters = await createReporters(config, 'merge', false, reporterDescriptions);
const multiplexer = new Multiplexer(reporters);
const stringPool = new StringInternPool();

View file

@ -32,7 +32,7 @@ import { dependenciesForTestFile } from '../transform/compilationCache';
import { sourceMapSupport } from '../utilsBundle';
import type { RawSourceMap } from 'source-map';
export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean, additionalFileMatcher: Matcher | undefined) {
export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean, additionalFileMatcher?: Matcher) {
const config = testRun.config;
const fsCache = new Map();
const sourceMapCache = new Map();
@ -60,8 +60,7 @@ export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTest
return hasMatchingSources;
});
const filteredFiles = matchedFiles.filter(Boolean) as string[];
if (filteredFiles.length)
filesToRunByProject.set(project, filteredFiles);
filesToRunByProject.set(project, filteredFiles);
}
// (Re-)add all files for dependent projects, disregard filters.
@ -316,7 +315,7 @@ export function loadGlobalHook(config: FullConfigInternal, file: string): Promis
return requireOrImportDefaultFunction(path.resolve(config.config.rootDir, file), false);
}
export function loadReporter(config: FullConfigInternal, file: string): Promise<new (arg?: any) => Reporter> {
export function loadReporter(config: FullConfigInternal | null, file: string): Promise<new (arg?: any) => Reporter> {
return requireOrImportDefaultFunction(config ? path.resolve(config.config.rootDir, file) : file, true);
}

View file

@ -112,16 +112,6 @@ export class ProcessHost extends EventEmitter {
return error;
const processParams: ProcessInitParams = {
stdoutParams: {
rows: process.stdout.rows,
columns: process.stdout.columns,
colorDepth: process.stdout.getColorDepth?.() || 8
},
stderrParams: {
rows: process.stderr.rows,
columns: process.stderr.columns,
colorDepth: process.stderr.getColorDepth?.() || 8
},
processName: this._processName
};

View file

@ -33,7 +33,7 @@ import { BlobReporter } from '../reporters/blob';
import type { ReporterDescription } from '../../types/test';
import { type ReporterV2, wrapReporterAsV2 } from '../reporters/reporterV2';
export async function createReporters(config: FullConfigInternal, mode: 'list' | 'test' | 'ui' | 'merge', descriptions?: ReporterDescription[]): Promise<ReporterV2[]> {
export async function createReporters(config: FullConfigInternal, mode: 'list' | 'test' | 'merge', isTestServer: boolean, descriptions?: ReporterDescription[]): Promise<ReporterV2[]> {
const defaultReporters: { [key in BuiltInReporter]: new(arg: any) => ReporterV2 } = {
blob: BlobReporter,
dot: mode === 'list' ? ListModeReporter : DotReporter,
@ -43,14 +43,14 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' |
json: JSONReporter,
junit: JUnitReporter,
null: EmptyReporter,
html: mode === 'ui' ? LineReporter : HtmlReporter,
html: HtmlReporter,
markdown: MarkdownReporter,
};
const reporters: ReporterV2[] = [];
descriptions ??= config.config.reporter;
if (config.configCLIOverrides.additionalReporters)
descriptions = [...descriptions, ...config.configCLIOverrides.additionalReporters];
const runOptions = reporterOptions(config, mode);
const runOptions = reporterOptions(config, mode, isTestServer);
for (const r of descriptions) {
const [name, arg] = r;
const options = { ...runOptions, ...arg };
@ -78,17 +78,18 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' |
return reporters;
}
export async function createReporterForTestServer(config: FullConfigInternal, file: string, mode: 'test' | 'list', messageSink: (message: any) => void): Promise<ReporterV2> {
const reporterConstructor = await loadReporter(config, file);
const runOptions = reporterOptions(config, mode, messageSink);
const instance = new reporterConstructor(runOptions);
return wrapReporterAsV2(instance);
export async function createReporterForTestServer(file: string, messageSink: (message: any) => void): Promise<ReporterV2> {
const reporterConstructor = await loadReporter(null, file);
return wrapReporterAsV2(new reporterConstructor({
_send: messageSink,
}));
}
function reporterOptions(config: FullConfigInternal, mode: 'list' | 'test' | 'ui' | 'merge', send?: (message: any) => void) {
function reporterOptions(config: FullConfigInternal, mode: 'list' | 'test' | 'merge', isTestServer: boolean) {
return {
configDir: config.configDir,
_send: send,
_mode: mode,
_isTestServer: isTestServer,
};
}

View file

@ -16,8 +16,7 @@
*/
import path from 'path';
import type { HttpServer, ManualPromise } from 'playwright-core/lib/utils';
import { isUnderTest, monotonicTime } from 'playwright-core/lib/utils';
import { monotonicTime } from 'playwright-core/lib/utils';
import type { FullResult, TestError } from '../../types/testReporter';
import { webServerPluginsForConfig } from '../plugins/webServerPlugin';
import { collectFilesForProject, filterProjects } from './projectUtils';
@ -26,13 +25,11 @@ import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks';
import type { FullConfigInternal } from '../common/config';
import { colors } from 'playwright-core/lib/utilsBundle';
import { runWatchModeLoop } from './watchMode';
import { runTestServer } from './testServer';
import { InternalReporter } from '../reporters/internalReporter';
import { Multiplexer } from '../reporters/multiplexer';
import type { Suite } from '../common/test';
import { wrapReporterAsV2 } from '../reporters/reporterV2';
import { affectedTestFiles } from '../transform/compilationCache';
import { installRootRedirect, openTraceInBrowser, openTraceViewerApp } from 'playwright-core/lib/server';
type ProjectConfigWithFiles = {
name: string;
@ -43,7 +40,6 @@ type ProjectConfigWithFiles = {
type ConfigListFilesReport = {
projects: ProjectConfigWithFiles[];
cliEntryPoint?: string;
error?: TestError;
};
@ -59,12 +55,10 @@ export class Runner {
this._config = config;
}
async listTestFiles(): Promise<ConfigListFilesReport> {
const frameworkPackage = (this._config.config as any)['@playwright/test']?.['packageJSON'];
const projects = filterProjects(this._config.projects);
async listTestFiles(projectNames?: string[]): Promise<ConfigListFilesReport> {
const projects = filterProjects(this._config.projects, projectNames);
const report: ConfigListFilesReport = {
projects: [],
cliEntryPoint: frameworkPackage ? path.join(path.dirname(frameworkPackage), 'cli.js') : undefined,
};
for (const project of projects) {
report.projects.push({
@ -85,7 +79,7 @@ export class Runner {
// Legacy webServer support.
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
const reporter = new InternalReporter(new Multiplexer(await createReporters(config, listOnly ? 'list' : 'test')));
const reporter = new InternalReporter(new Multiplexer(await createReporters(config, listOnly ? 'list' : 'test', false)));
const taskRunner = listOnly ? createTaskRunnerForList(config, reporter, 'in-process', { failOnLoadErrors: true })
: createTaskRunner(config, reporter);
@ -148,34 +142,6 @@ export class Runner {
return await runWatchModeLoop(config);
}
async runUIMode(options: { host?: string, port?: number }): Promise<FullResult['status']> {
const config = this._config;
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
return await runTestServer(config, options, async (server: HttpServer, cancelPromise: ManualPromise<void>) => {
await installRootRedirect(server, [], { webApp: 'uiMode.html' });
if (options.host !== undefined || options.port !== undefined) {
await openTraceInBrowser(server.urlPrefix());
} else {
const page = await openTraceViewerApp(server.urlPrefix(), 'chromium', {
headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1',
persistentContextOptions: {
handleSIGINT: false,
},
});
page.on('close', () => cancelPromise.resolve());
}
});
}
async runTestServer(options: { host?: string, port?: number }): Promise<FullResult['status']> {
const config = this._config;
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
return await runTestServer(config, options, async server => {
// eslint-disable-next-line no-console
console.log('Listening on ' + server.urlPrefix().replace('http:', 'ws:') + '/' + server.wsGuid());
});
}
async findRelatedTestFiles(mode: 'in-process' | 'out-of-process', files: string[]): Promise<FindRelatedTestFilesReport> {
const result = await this.loadAllTests(mode);
if (result.status !== 'passed' || !result.suite)

View file

@ -28,7 +28,7 @@ import { TaskRunner } from './taskRunner';
import type { FullConfigInternal, FullProjectInternal } from '../common/config';
import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook } from './loadUtils';
import type { Matcher } from '../util';
import type { Suite } from '../common/test';
import { Suite } from '../common/test';
import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils';
import { FailureTracker } from './failureTracker';
@ -116,6 +116,13 @@ export function createTaskRunnerForList(config: FullConfigInternal, reporter: Re
return taskRunner;
}
export function createTaskRunnerForListFiles(config: FullConfigInternal, reporter: ReporterV2): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout);
taskRunner.addTask('load tests', createListFilesTask());
taskRunner.addTask('report begin', createReportBeginTask());
return taskRunner;
}
function createReportBeginTask(): Task<TestRun> {
return {
setup: async ({ reporter, rootSuite }) => {
@ -195,6 +202,29 @@ function createRemoveOutputDirsTask(): Task<TestRun> {
};
}
function createListFilesTask(): Task<TestRun> {
return {
setup: async (testRun, errors) => {
testRun.rootSuite = await createRootSuite(testRun, errors, false);
testRun.failureTracker.onRootSuite(testRun.rootSuite);
await collectProjectsAndTestFiles(testRun, false);
for (const [project, files] of testRun.projectFiles) {
const projectSuite = new Suite(project.project.name, 'project');
projectSuite._fullProject = project;
testRun.rootSuite._addSuite(projectSuite);
const suites = files.map(file => {
const title = path.relative(testRun.config.config.rootDir, file);
const suite = new Suite(title, 'file');
suite.location = { file, line: 0, column: 0 };
projectSuite._addSuite(suite);
return suite;
});
testRun.projectSuites.set(project, suites);
}
},
};
}
function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task<TestRun> {
return {
setup: async (testRun, errors, softErrors) => {

View file

@ -16,7 +16,7 @@
import fs from 'fs';
import path from 'path';
import { registry, startTraceViewerServer } from 'playwright-core/lib/server';
import { installRootRedirect, openTraceInBrowser, openTraceViewerApp, registry, startTraceViewerServer } from 'playwright-core/lib/server';
import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils';
import type { Transport, HttpServer } from 'playwright-core/lib/utils';
import type * as reporterTypes from '../../types/testReporter';
@ -24,43 +24,35 @@ import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/
import type { FullConfigInternal } from '../common/config';
import { InternalReporter } from '../reporters/internalReporter';
import { createReporterForTestServer, createReporters } from './reporters';
import { TestRun, createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks';
import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer, createTaskRunnerForWatchSetup, createTaskRunnerForListFiles } from './tasks';
import { open } from 'playwright-core/lib/utilsBundle';
import ListReporter from '../reporters/list';
import { Multiplexer } from '../reporters/multiplexer';
import { SigIntWatcher } from './sigIntWatcher';
import { Watcher } from '../fsWatcher';
import type { TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface';
import type { ReportEntry, TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface';
import { Runner } from './runner';
import type { ConfigCLIOverrides } from '../common/ipc';
import { loadConfig, resolveConfigFile, restartWithExperimentalTsEsm } from '../common/configLoader';
import { webServerPluginsForConfig } from '../plugins/webServerPlugin';
import type { TraceViewerRedirectOptions, TraceViewerServerOptions } from 'playwright-core/lib/server/trace/viewer/traceViewer';
import type { TestRunnerPluginRegistration } from '../plugins';
import { serializeError } from '../util';
import { prepareErrorStack } from '../reporters/base';
class TestServer {
private _config: FullConfigInternal;
private _configFile: string | undefined;
private _dispatcher: TestServerDispatcher | undefined;
private _originalStdoutWrite: NodeJS.WriteStream['write'];
private _originalStderrWrite: NodeJS.WriteStream['write'];
constructor(config: FullConfigInternal) {
this._config = config;
process.env.PW_LIVE_TRACE_STACKS = '1';
config.cliListOnly = false;
config.cliPassWithNoTests = true;
config.config.preserveOutput = 'always';
for (const p of config.projects) {
p.project.retries = 0;
p.project.repeatEach = 1;
}
config.configCLIOverrides.use = config.configCLIOverrides.use || {};
config.configCLIOverrides.use.trace = { mode: 'on', sources: false, _live: true };
constructor(configFile: string | undefined) {
this._configFile = configFile;
this._originalStdoutWrite = process.stdout.write;
this._originalStderrWrite = process.stderr.write;
}
async start(options: { host?: string, port?: number }): Promise<HttpServer> {
this._dispatcher = new TestServerDispatcher(this._config);
this._dispatcher = new TestServerDispatcher(this._configFile);
return await startTraceViewerServer({ ...options, transport: this._dispatcher.transport });
}
@ -90,20 +82,23 @@ class TestServer {
}
class TestServerDispatcher implements TestServerInterface {
private _config: FullConfigInternal;
private _configFile: string | undefined;
private _globalWatcher: Watcher;
private _testWatcher: Watcher;
private _testRun: { run: Promise<reporterTypes.FullResult['status']>, stop: ManualPromise<void> } | undefined;
readonly transport: Transport;
private _queue = Promise.resolve();
private _globalCleanup: (() => Promise<reporterTypes.FullResult['status']>) | undefined;
private _globalSetup: { cleanup: () => Promise<any>, report: ReportEntry[] } | undefined;
readonly _dispatchEvent: TestServerInterfaceEventEmitters['dispatchEvent'];
private _plugins: TestRunnerPluginRegistration[] | undefined;
private _serializer = require.resolve('./uiModeReporter');
private _watchTestDir = false;
constructor(config: FullConfigInternal) {
this._config = config;
constructor(configFile: string | undefined) {
this._configFile = configFile;
this.transport = {
dispatch: (method, params) => (this as any)[method](params),
onclose: () => {},
onclose: () => gracefullyProcessExitDoNotHang(0),
};
this._globalWatcher = new Watcher('deep', () => this._dispatchEvent('listChanged', {}));
this._testWatcher = new Watcher('flat', events => {
@ -114,16 +109,33 @@ class TestServerDispatcher implements TestServerInterface {
this._dispatchEvent = (method, params) => this.transport.sendEvent?.(method, params);
}
async setSerializer(params: { serializer: string; }): Promise<void> {
this._serializer = params.serializer;
}
private async _wireReporter(messageSink: (message: any) => void) {
return await createReporterForTestServer(this._serializer, messageSink);
}
private async _collectingReporter() {
const report: ReportEntry[] = [];
const wireReporter = await createReporterForTestServer(this._serializer, e => report.push(e));
const reporter = new InternalReporter(wireReporter);
return { reporter, report };
}
async ready() {}
async ping() {}
async open(params: { location: reporterTypes.Location }) {
async open(params: Parameters<TestServerInterface['open']>[0]): ReturnType<TestServerInterface['open']> {
if (isUnderTest())
return;
// eslint-disable-next-line no-console
open('vscode://file/' + params.location.file + ':' + params.location.line).catch(e => console.error(e));
}
async resizeTerminal(params: { cols: number; rows: number; }) {
async resizeTerminal(params: Parameters<TestServerInterface['resizeTerminal']>[0]): ReturnType<TestServerInterface['resizeTerminal']> {
process.stdout.columns = params.cols;
process.stdout.rows = params.rows;
process.stderr.columns = params.cols;
@ -138,117 +150,173 @@ class TestServerDispatcher implements TestServerInterface {
await installBrowsers();
}
async runGlobalSetup(): Promise<reporterTypes.FullResult['status']> {
async runGlobalSetup(params: Parameters<TestServerInterface['runGlobalSetup']>[0]): ReturnType<TestServerInterface['runGlobalSetup']> {
await this.runGlobalTeardown();
const reporter = new InternalReporter(new ListReporter());
const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter);
reporter.onConfigure(this._config.config);
const testRun = new TestRun(this._config, reporter);
const { reporter, report } = await this._collectingReporter();
const { config, error } = await this._loadConfig(this._configFile);
if (!config) {
reporter.onError(error!);
return { status: 'failed', report };
}
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
const listReporter = new InternalReporter(new ListReporter());
const taskRunner = createTaskRunnerForWatchSetup(config, new Multiplexer([reporter, listReporter]));
reporter.onConfigure(config.config);
const testRun = new TestRun(config, reporter);
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0);
await reporter.onEnd({ status });
await reporter.onExit();
if (status !== 'passed') {
await globalCleanup();
return status;
return { report, status };
}
this._globalCleanup = globalCleanup;
return status;
this._globalSetup = { cleanup: globalCleanup, report };
return { report, status };
}
async runGlobalTeardown() {
const result = (await this._globalCleanup?.()) || 'passed';
this._globalCleanup = undefined;
return result;
const globalSetup = this._globalSetup;
const status = await globalSetup?.cleanup();
this._globalSetup = undefined;
return { status, report: globalSetup?.report || [] };
}
async listFiles() {
try {
const runner = new Runner(this._config);
return runner.listTestFiles();
} catch (e) {
const error: reporterTypes.TestError = serializeError(e);
error.location = prepareErrorStack(e.stack).location;
return { projects: [], error };
async listFiles(params: Parameters<TestServerInterface['listFiles']>[0]): ReturnType<TestServerInterface['listFiles']> {
const { reporter, report } = await this._collectingReporter();
const { config, error } = await this._loadConfig(this._configFile);
if (!config) {
reporter.onError(error!);
return { status: 'failed', report };
}
config.cliProjectFilter = params.projects?.length ? params.projects : undefined;
const taskRunner = createTaskRunnerForListFiles(config, reporter);
reporter.onConfigure(config.config);
const testRun = new TestRun(config, reporter);
const status = await taskRunner.run(testRun, 0);
await reporter.onEnd({ status });
await reporter.onExit();
return { report, status };
}
async listTests(params: { reporter?: string; fileNames: string[]; }) {
let report: any[] = [];
async listTests(params: Parameters<TestServerInterface['listTests']>[0]): ReturnType<TestServerInterface['listTests']> {
let result: Awaited<ReturnType<TestServerInterface['listTests']>>;
this._queue = this._queue.then(async () => {
report = await this._innerListTests(params);
result = await this._innerListTests(params);
}).catch(printInternalError);
await this._queue;
return { report };
return result!;
}
private async _innerListTests(params: { reporter?: string; fileNames?: string[]; }) {
const report: any[] = [];
const wireReporter = await createReporterForTestServer(this._config, params.reporter || require.resolve('./uiModeReporter'), 'list', e => report.push(e));
const reporter = new InternalReporter(wireReporter);
this._config.cliArgs = params.fileNames || [];
this._config.cliListOnly = true;
this._config.testIdMatcher = undefined;
const taskRunner = createTaskRunnerForList(this._config, reporter, 'out-of-process', { failOnLoadErrors: false });
const testRun = new TestRun(this._config, reporter);
reporter.onConfigure(this._config.config);
private async _innerListTests(params: Parameters<TestServerInterface['listTests']>[0]): ReturnType<TestServerInterface['listTests']> {
const overrides: ConfigCLIOverrides = {
repeatEach: 1,
retries: 0,
};
const { reporter, report } = await this._collectingReporter();
const { config, error } = await this._loadConfig(this._configFile, overrides);
if (!config) {
reporter.onError(error!);
return { report: [], status: 'failed' };
}
config.cliArgs = params.locations || [];
config.cliProjectFilter = params.projects?.length ? params.projects : undefined;
config.cliListOnly = true;
const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-process', { failOnLoadErrors: false });
const testRun = new TestRun(config, reporter);
reporter.onConfigure(config.config);
const status = await taskRunner.run(testRun, 0);
await reporter.onEnd({ status });
await reporter.onExit();
const projectDirs = new Set<string>();
const projectOutputs = new Set<string>();
for (const p of this._config.projects) {
for (const p of config.projects) {
projectDirs.add(p.project.testDir);
projectOutputs.add(p.project.outputDir);
}
const result = await resolveCtDirs(this._config);
const result = await resolveCtDirs(config);
if (result) {
projectDirs.add(result.templateDir);
projectOutputs.add(result.outDir);
}
this._globalWatcher.update([...projectDirs], [...projectOutputs], false);
return report;
if (this._watchTestDir)
this._globalWatcher.update([...projectDirs], [...projectOutputs], false);
return { report, status };
}
async runTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }) {
let status: reporterTypes.FullResult['status'];
async runTests(params: Parameters<TestServerInterface['runTests']>[0]): ReturnType<TestServerInterface['runTests']> {
let result: Awaited<ReturnType<TestServerInterface['runTests']>> = { status: 'passed' };
this._queue = this._queue.then(async () => {
status = await this._innerRunTests(params).catch(printInternalError) || 'failed';
result = await this._innerRunTests(params).catch(e => { printInternalError(e); return { status: 'failed' }; });
});
await this._queue;
return { status: status! };
return result;
}
private async _innerRunTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }): Promise<reporterTypes.FullResult['status']> {
private async _innerRunTests(params: Parameters<TestServerInterface['runTests']>[0]): ReturnType<TestServerInterface['runTests']> {
await this.stopTests();
const { testIds, projects, locations, grep } = params;
const overrides: ConfigCLIOverrides = {
repeatEach: 1,
retries: 0,
preserveOutputDir: true,
timeout: params.timeout,
reporter: params.reporters ? params.reporters.map(r => [r]) : undefined,
use: {
trace: params.trace === 'on' ? { mode: 'on', sources: false, _live: true } : (params.trace === 'off' ? 'off' : undefined),
headless: params.headed ? false : undefined,
_optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
},
workers: params.workers,
};
if (params.trace === 'on')
process.env.PW_LIVE_TRACE_STACKS = '1';
else
process.env.PW_LIVE_TRACE_STACKS = undefined;
const testIdSet = testIds ? new Set<string>(testIds) : null;
this._config.cliArgs = locations ? locations : [];
this._config.cliGrep = grep;
this._config.cliListOnly = false;
this._config.cliProjectFilter = projects?.length ? projects : undefined;
this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id);
const { config, error } = await this._loadConfig(this._configFile, overrides);
if (!config) {
const wireReporter = await this._wireReporter(e => this._dispatchEvent('report', e));
wireReporter.onError(error!);
return { status: 'failed' };
}
const reporters = await createReporters(this._config, 'ui');
reporters.push(await createReporterForTestServer(this._config, params.reporter || require.resolve('./uiModeReporter'), 'list', e => this._dispatchEvent('report', e)));
const testIdSet = params.testIds ? new Set<string>(params.testIds) : null;
config.cliListOnly = false;
config.cliPassWithNoTests = true;
config.cliArgs = params.locations || [];
config.cliGrep = params.grep;
config.cliGrepInvert = params.grepInvert;
config.cliProjectFilter = params.projects?.length ? params.projects : undefined;
config.testIdMatcher = testIdSet ? id => testIdSet.has(id) : undefined;
const reporters = await createReporters(config, 'test', true);
const wireReporter = await this._wireReporter(e => this._dispatchEvent('report', e));
reporters.push(wireReporter);
const reporter = new InternalReporter(new Multiplexer(reporters));
const taskRunner = createTaskRunnerForWatch(this._config, reporter);
const testRun = new TestRun(this._config, reporter);
reporter.onConfigure(this._config.config);
const taskRunner = createTaskRunnerForTestServer(config, reporter);
const testRun = new TestRun(config, reporter);
reporter.onConfigure(config.config);
const stop = new ManualPromise();
const run = taskRunner.run(testRun, 0, stop).then(async status => {
await reporter.onEnd({ status });
await reporter.onExit();
this._testRun = undefined;
this._config.testIdMatcher = undefined;
return status;
});
this._testRun = { run, stop };
return await run;
return { status: await run };
}
async watchTestDir() {
this._watchTestDir = true;
}
async watch(params: { fileNames: string[]; }) {
@ -260,8 +328,11 @@ class TestServerDispatcher implements TestServerInterface {
this._testWatcher.update([...files], [], true);
}
findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[] | undefined; }> {
const runner = new Runner(this._config);
async findRelatedTestFiles(params: Parameters<TestServerInterface['findRelatedTestFiles']>[0]): ReturnType<TestServerInterface['findRelatedTestFiles']> {
const { config, error } = await this._loadConfig(this._configFile);
if (error)
return { testFiles: [], errors: [error] };
const runner = new Runner(config!);
return runner.findRelatedTestFiles('out-of-process', params.files);
}
@ -273,10 +344,52 @@ class TestServerDispatcher implements TestServerInterface {
async closeGracefully() {
gracefullyProcessExitDoNotHang(0);
}
private async _loadConfig(configFile: string | undefined, overrides?: ConfigCLIOverrides): Promise<{ config: FullConfigInternal | null, error?: reporterTypes.TestError }> {
const configFileOrDirectory = configFile ? path.resolve(process.cwd(), configFile) : process.cwd();
const resolvedConfigFile = resolveConfigFile(configFileOrDirectory);
try {
const config = await loadConfig({ resolvedConfigFile, configDir: resolvedConfigFile === configFileOrDirectory ? path.dirname(resolvedConfigFile) : configFileOrDirectory }, overrides);
// Preserve plugin instances between setup and build.
if (!this._plugins)
this._plugins = config.plugins || [];
else
config.plugins.splice(0, config.plugins.length, ...this._plugins);
return { config };
} catch (e) {
return { config: null, error: serializeError(e) };
}
}
}
export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>) => Promise<void>): Promise<reporterTypes.FullResult['status']> {
const testServer = new TestServer(config);
export async function runUIMode(configFile: string | undefined, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise<reporterTypes.FullResult['status']> {
return await innerRunTestServer(configFile, options, async (server: HttpServer, cancelPromise: ManualPromise<void>) => {
await installRootRedirect(server, [], { ...options, webApp: 'uiMode.html' });
if (options.host !== undefined || options.port !== undefined) {
await openTraceInBrowser(server.urlPrefix());
} else {
const page = await openTraceViewerApp(server.urlPrefix(), 'chromium', {
headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1',
persistentContextOptions: {
handleSIGINT: false,
},
});
page.on('close', () => cancelPromise.resolve());
}
});
}
export async function runTestServer(configFile: string | undefined, options: { host?: string, port?: number }): Promise<reporterTypes.FullResult['status']> {
return await innerRunTestServer(configFile, options, async server => {
// eslint-disable-next-line no-console
console.log('Listening on ' + server.urlPrefix().replace('http:', 'ws:') + '/' + server.wsGuid());
});
}
async function innerRunTestServer(configFile: string | undefined, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>) => Promise<void>): Promise<reporterTypes.FullResult['status']> {
if (restartWithExperimentalTsEsm(undefined, true))
return 'passed';
const testServer = new TestServer(configFile);
const cancelPromise = new ManualPromise<void>();
const sigintWatcher = new SigIntWatcher();
void sigintWatcher.promise().then(() => cancelPromise.resolve());

View file

@ -137,14 +137,10 @@ export class TimeoutManager {
switch (runnable.type) {
case 'test': {
if (runnable.fixture) {
if (runnable.fixture.phase === 'setup') {
if (runnable.fixture.phase === 'setup')
message = `Test timeout of ${timeout}ms exceeded while setting up "${runnable.fixture.title}".`;
} else {
message = [
`Test finished within timeout of ${timeout}ms, but tearing down "${runnable.fixture.title}" ran out of time.`,
`Please allow more time for the test, since teardown is attributed towards the test timeout budget.`,
].join('\n');
}
else
message = `Tearing down "${runnable.fixture.title}" exceeded the test timeout of ${timeout}ms.`;
} else {
message = `Test timeout of ${timeout}ms exceeded.`;
}

View file

@ -5590,10 +5590,11 @@ export interface PlaywrightWorkerOptions {
* Whether to record trace for each test. Defaults to `'off'`.
* - `'off'`: Do not record trace.
* - `'on'`: Record trace for each test.
* - `'retain-on-failure'`: Record trace for each test, but remove all traces from successful test runs.
* - `'on-first-retry'`: Record trace only when retrying a test for the first time.
* - `'on-all-retries'`: Record traces only when retrying for all retries.
* - `'retain-on-first-failure'`: Record traces only when the test fails for the first time.
* - `'on-all-retries'`: Record trace only when retrying a test.
* - `'retain-on-failure'`: Record trace for each test. When test run passes, remove the recorded trace.
* - `'retain-on-first-failure'`: Record trace for the first run of each test, but not for retires. When test run
* passes, remove the recorded trace.
*
* For more control, pass an object that specifies `mode` and trace features to enable.
*

View file

@ -0,0 +1,92 @@
/**
* 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.
*/
.tag {
display: inline-block;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
border: 1px solid transparent;
border-radius: 2em;
background-color: #8c959f;
color: white;
margin: 0 10px;
flex: none;
font-weight: 600;
}
.light-mode .tag-color-0 {
background-color: #ddf4ff;
color: #0550ae;
border: 1px solid #218bff;
}
.light-mode .tag-color-1 {
background-color: #fff8c5;
color: #7d4e00;
border: 1px solid #bf8700;
}
.light-mode .tag-color-2 {
background-color: #fbefff;
color: #6e40c9;
border: 1px solid #a475f9;
}
.light-mode .tag-color-3 {
background-color: #ffeff7;
color: #99286e;
border: 1px solid #e85aad;
}
.light-mode .tag-color-4 {
background-color: #FFF0EB;
color: #9E2F1C;
border: 1px solid #EA6045;
}
.light-mode .tag-color-5 {
background-color: #fff1e5;
color: #9b4215;
border: 1px solid #e16f24;
}
.dark-mode .tag-color-0 {
background-color: #051d4d;
color: #80ccff;
border: 1px solid #218bff;
}
.dark-mode .tag-color-1 {
background-color: #3b2300;
color: #eac54f;
border: 1px solid #bf8700;
}
.dark-mode .tag-color-2 {
background-color: #271052;
color: #d2a8ff;
border: 1px solid #a475f9;
}
.dark-mode .tag-color-3 {
background-color: #42062a;
color: #ff9bce;
border: 1px solid #e85aad;
}
.dark-mode .tag-color-4 {
background-color: #460701;
color: #FFA28B;
border: 1px solid #EC6547;
}
.dark-mode .tag-color-5 {
background-color: #471700;
color: #ffa657;
border: 1px solid #e16f24;
}

View file

@ -0,0 +1,36 @@
/**
* 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 './tag.css';
export const TagView: React.FC<{ tag: string, style?: React.CSSProperties, onClick?: (e: React.MouseEvent) => void }> = ({ tag, style, onClick }) => {
return <span
className={`tag tag-color-${tagNameToColor(tag)}`}
onClick={onClick}
style={{ margin: '6px 0 0 6px', ...style }}
title={`Click to filter by tag: ${tag}`}
>
{tag}
</span>;
};
// hash string to integer in range [0, 6] for color index, to get same color for same tag
function tagNameToColor(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++)
hash = str.charCodeAt(i) + ((hash << 8) - hash);
return Math.abs(hash % 6);
}

View file

@ -30,6 +30,7 @@ import { testStatusIcon } from './testUtils';
import type { TestModel } from './uiModeModel';
import './uiModeTestListView.css';
import type { TestServerConnection } from '@testIsomorphic/testServerConnection';
import { TagView } from './tag';
const TestTreeView = TreeView<TreeItem>;
@ -46,7 +47,8 @@ export const TestListView: React.FC<{
isLoading?: boolean,
onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void,
requestedCollapseAllCount: number,
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount }) => {
setFilterText: (text: string) => void;
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, setFilterText }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount);
@ -132,6 +134,21 @@ export const TestListView: React.FC<{
runTests('bounce-if-busy', testTree.collectTestIds(treeItem));
};
const handleTagClick = (e: React.MouseEvent, tag: string) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey) {
const parts = filterText.split(' ');
if (parts.includes(tag))
setFilterText(parts.filter(t => t !== tag).join(' ').trim());
else
setFilterText((filterText + ' ' + tag).trim());
} else {
// Replace all existing tags with this tag.
setFilterText((filterText.split(' ').filter(t => !t.startsWith('@')).join(' ') + ' ' + tag).trim());
}
};
return <TestTreeView
name='tests'
treeState={treeState}
@ -140,7 +157,10 @@ export const TestListView: React.FC<{
dataTestId='test-tree'
render={treeItem => {
return <div className='hbox ui-mode-list-item'>
<div className='ui-mode-list-item-title' title={treeItem.title}>{treeItem.title}</div>
<div className='ui-mode-list-item-title'>
<span title={treeItem.title}>{treeItem.title}</span>
{treeItem.kind === 'case' ? treeItem.tags.map(tag => <TagView key={tag} tag={tag.slice(1)} onClick={e => handleTagClick(e, tag)} />) : null}
</div>
{!!treeItem.duration && treeItem.status !== 'skipped' && <div className='ui-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
<Toolbar noMinHeight={true} noShadow={true}>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>

View file

@ -48,6 +48,23 @@ const xtermDataSource: XtermDataSource = {
resize: () => {},
};
const searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws');
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const queryParams = {
args: searchParams.getAll('arg'),
grep: searchParams.get('grep') || undefined,
grepInvert: searchParams.get('grepInvert') || undefined,
projects: searchParams.getAll('project'),
workers: searchParams.get('workers') || undefined,
timeout: searchParams.has('timeout') ? +searchParams.get('timeout')! : undefined,
headed: searchParams.has('headed'),
reporters: searchParams.has('reporter') ? searchParams.getAll('reporter') : undefined,
};
const isMac = navigator.platform === 'MacIntel';
export const UIModeView: React.FC<{}> = ({
}) => {
const [filterText, setFilterText] = React.useState<string>('');
@ -76,9 +93,6 @@ export const UIModeView: React.FC<{}> = ({
const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = React.useCallback(() => {
const guid = new URLSearchParams(window.location.search).get('ws');
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
setTestServerConnection(new TestServerConnection(wsURL.toString()));
}, []);
@ -143,7 +157,7 @@ export const UIModeView: React.FC<{}> = ({
commandQueue.current = commandQueue.current.then(async () => {
setIsLoading(true);
try {
const result = await testServerConnection.listTests({});
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args });
teleSuiteUpdater.processListReport(result.report);
} catch (e) {
// eslint-disable-next-line no-console
@ -158,10 +172,11 @@ export const UIModeView: React.FC<{}> = ({
setIsLoading(true);
setWatchedTreeIds({ value: new Set() });
(async () => {
const status = await testServerConnection.runGlobalSetup();
await testServerConnection.watchTestDir({});
const { status } = await testServerConnection.runGlobalSetup({});
if (status !== 'passed')
return;
const result = await testServerConnection.listTests({});
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args });
teleSuiteUpdater.processListReport(result.report);
testServerConnection.onListChanged(updateList);
@ -170,7 +185,7 @@ export const UIModeView: React.FC<{}> = ({
});
setIsLoading(false);
const { hasBrowsers } = await testServerConnection.checkBrowsers();
const { hasBrowsers } = await testServerConnection.checkBrowsers({});
setHasBrowsers(hasBrowsers);
})();
return () => {
@ -251,7 +266,18 @@ export const UIModeView: React.FC<{}> = ({
setProgress({ total: 0, passed: 0, failed: 0, skipped: 0 });
setRunningState({ testIds });
await testServerConnection.runTests({ testIds: [...testIds], projects: [...projectFilters].filter(([_, v]) => v).map(([p]) => p) });
await testServerConnection.runTests({
locations: queryParams.args,
grep: queryParams.grep,
grepInvert: queryParams.grepInvert,
testIds: [...testIds],
projects: [...projectFilters].filter(([_, v]) => v).map(([p]) => p),
workers: queryParams.workers,
timeout: queryParams.timeout,
headed: queryParams.headed,
reporters: queryParams.reporters,
trace: 'on',
});
// Clear pending tests in case of interrupt.
for (const test of testModel.rootSuite?.allTests() || []) {
if (test.results[0]?.duration === -1)
@ -296,19 +322,22 @@ export const UIModeView: React.FC<{}> = ({
if (!testServerConnection)
return;
const onShortcutEvent = (e: KeyboardEvent) => {
if (e.code === 'F6') {
if (e.code === 'Backquote' && e.ctrlKey) {
e.preventDefault();
testServerConnection?.stopTestsNoReply();
setIsShowingOutput(!isShowingOutput);
} else if (e.code === 'F5' && e.shiftKey) {
e.preventDefault();
testServerConnection?.stopTestsNoReply({});
} else if (e.code === 'F5') {
e.preventDefault();
reloadTests();
runTests('bounce-if-busy', visibleTestIds);
}
};
addEventListener('keydown', onShortcutEvent);
return () => {
removeEventListener('keydown', onShortcutEvent);
};
}, [runTests, reloadTests, testServerConnection]);
}, [runTests, reloadTests, testServerConnection, visibleTestIds, isShowingOutput]);
const isRunningTest = !!runningState;
const dialogRef = React.useRef<HTMLDialogElement>(null);
@ -325,9 +354,9 @@ export const UIModeView: React.FC<{}> = ({
const installBrowsers = React.useCallback((e: React.MouseEvent) => {
closeInstallDialog(e);
setIsShowingOutput(true);
testServerConnection?.installBrowsers().then(async () => {
testServerConnection?.installBrowsers({}).then(async () => {
setIsShowingOutput(false);
const { hasBrowsers } = await testServerConnection?.checkBrowsers();
const { hasBrowsers } = await testServerConnection?.checkBrowsers({});
setHasBrowsers(hasBrowsers);
});
}, [closeInstallDialog, testServerConnection]);
@ -369,7 +398,7 @@ export const UIModeView: React.FC<{}> = ({
<div className='section-title'>Playwright</div>
<ToolbarButton icon='color-mode' title='Toggle color mode' onClick={() => toggleTheme()} />
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
<ToolbarButton icon='terminal' title={'Toggle output — ' + (isMac ? '⌃`' : 'Ctrl + `')} toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
{!hasBrowsers && <ToolbarButton icon='lightbulb-autofix' style={{ color: 'var(--vscode-list-warningForeground)' }} title='Playwright browsers are missing' onClick={openInstallDialog} />}
</Toolbar>
<FiltersView
@ -389,8 +418,8 @@ export const UIModeView: React.FC<{}> = ({
{isRunningTest && progress && <div data-testid='status-line' className='status-line'>
<div>Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)</div>
</div>}
<ToolbarButton icon='play' title='Run all' onClick={() => runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => testServerConnection?.stopTests()} disabled={!isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='play' title='Run all — F5' onClick={() => runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='debug-stop' title={'Stop — ' + (isMac ? '⇧F5' : 'Shift + F5')} onClick={() => testServerConnection?.stopTests({})} disabled={!isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch all' toggled={watchAll} onClick={() => {
setWatchedTreeIds({ value: new Set() });
setWatchAll(!watchAll);
@ -411,7 +440,9 @@ export const UIModeView: React.FC<{}> = ({
watchedTreeIds={watchedTreeIds}
setWatchedTreeIds={setWatchedTreeIds}
isLoading={isLoading}
requestedCollapseAllCount={collapseAllCount} />
requestedCollapseAllCount={collapseAllCount}
setFilterText={setFilterText}
/>
</div>
</SplitView>
</div>;

View file

@ -983,6 +983,40 @@ it('should support multipart/form-data and keep the order', async function({ con
expect(response.status()).toBe(200);
});
it('should support repeating names in multipart/form-data', async function({ context, server }) {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28070' });
const postBodyPromise = new Promise<string>(resolve => {
server.setRoute('/empty.html', async (req, res) => {
resolve((await req.postBody).toString('utf-8'));
res.writeHead(200, {
'content-type': 'text/plain',
});
res.end('OK.');
});
});
const [postBody, response] = await Promise.all([
postBodyPromise,
context.request.post(server.EMPTY_PAGE, {
multipart: {
firstName: 'John',
lastName: 'Doe',
file: [{
name: 'f1.js',
mimeType: 'text/javascript',
buffer: Buffer.from('var x = 10;\r\n;console.log(x);')
}, {
name: 'f2.txt',
mimeType: 'text/plain',
buffer: Buffer.from('hello')
}]
}
})
]);
expect(postBody).toContain(`content-disposition: form-data; name="file"; filename="f1.js"\r\ncontent-type: text/javascript\r\n\r\nvar x = 10;\r\n;console.log(x);`);
expect(postBody).toContain(`content-disposition: form-data; name="file"; filename="f2.txt"\r\ncontent-type: text/plain\r\n\r\nhello`);
expect(response.status()).toBe(200);
});
it('should serialize data to json regardless of content-type', async function({ context, server }) {
const data = {
firstName: 'John',

View file

@ -648,18 +648,4 @@ await page.GetByLabel("Coun\\"try").ClickAsync();`);
expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`);
expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`);
});
test('should assert screenshot', async ({ openRecorder }) => {
const recorder = await openRecorder();
await recorder.setContentAndWait(`<div>Hello, world</div>`);
const [sources] = await Promise.all([
recorder.waitForOutput('JavaScript', 'toHaveScreenshot'),
recorder.page.click('x-pw-tool-item.screenshot'),
]);
expect.soft(sources.get('JavaScript')!.text).toContain(`await expect(page).toHaveScreenshot()`);
expect.soft(sources.get('Python')!.text).toContain(`# assert_screenshot(page.screenshot())`);
expect.soft(sources.get('Python Async')!.text).toContain(`# assert_screenshot(await page.screenshot())`);
expect.soft(sources.get('Java')!.text).toContain(`// assertScreenshot(page.screenshot());`);
expect.soft(sources.get('C#')!.text).toContain(`// AssertScreenshot(await page.ScreenshotAsync())`);
});
});

View file

@ -299,22 +299,22 @@ it('should work with COEP/COOP/CORP isolated iframe', async ({ page, server, bro
expect(await page.frames()[1].evaluate(() => window['__clicked'])).toBe(true);
});
it('locator.enterFrame should work', async ({ page, server }) => {
it('locator.contentFrame should work', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const locator = page.locator('iframe');
const frameLocator = locator.enterFrame();
const frameLocator = locator.contentFrame();
const button = frameLocator.locator('button');
expect(await button.innerText()).toBe('Hello iframe');
await expect(button).toHaveText('Hello iframe');
await button.click();
});
it('frameLocator.exitFrame should work', async ({ page, server }) => {
it('frameLocator.owner should work', async ({ page, server }) => {
await routeIframe(page);
await page.goto(server.EMPTY_PAGE);
const frameLocator = page.frameLocator('iframe');
const locator = frameLocator.exitFrame();
const locator = frameLocator.owner();
await expect(locator).toBeVisible();
expect(await locator.getAttribute('name')).toBe('frame1');
});

View file

@ -348,7 +348,7 @@ it('should return body for prefetch script', async ({ page, server, browserName
expect(body.toString()).toBe('// Scripts will be pre-fetched');
});
it('should bypass disk cache when interception is enabled', async ({ page, server, browserName }) => {
it('should bypass disk cache when page interception is enabled', async ({ page, server, browserName }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30000' });
it.fixme(browserName === 'firefox', 'Returns cached response.');
await page.goto(server.PREFIX + '/frames/one-frame.html');
@ -399,3 +399,32 @@ it('should bypass disk cache when interception is enabled', async ({ page, serve
}
}
});
it('should bypass disk cache when context interception is enabled', async ({ page, server, browserName }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30000' });
it.fixme(browserName === 'firefox', 'Returns cached response.');
await page.context().route('**/api*', route => route.continue());
await page.goto(server.PREFIX + '/frames/one-frame.html');
{
const requests = [];
server.setRoute('/api', (req, res) => {
requests.push(req);
res.statusCode = 200;
res.setHeader('content-type', 'text/plain');
res.setHeader('cache-control', 'public, max-age=31536000');
res.end('Hello');
});
for (let i = 0; i < 3; i++) {
await it.step(`main frame iteration ${i}`, async () => {
const respPromise = page.waitForResponse('**/api');
await page.evaluate(async () => {
const response = await fetch('/api');
return response.status;
});
const response = await respPromise;
expect(response.status()).toBe(200);
expect(requests.length).toBe(i + 1);
});
}
}
});

View file

@ -438,3 +438,21 @@ it('should fulfill json', async ({ page, server }) => {
expect(response.headers()['content-type']).toBe('application/json');
expect(body).toBe(JSON.stringify({ bar: 'baz' }));
});
it('should fulfill with gzip and readback', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29261' },
}, async ({ page, server, isAndroid, isElectron }) => {
it.skip(isAndroid, 'The internal Android localhost (10.0.0.2) != the localhost on the host');
it.fixme(isElectron, 'error: Browser context management is not supported.');
server.enableGzip('/one-style.html');
await page.route('**/one-style.html', async route => {
const response = await route.fetch();
expect(response.headers()['content-encoding']).toBe('gzip');
await route.fulfill({ response });
});
const response = await page.goto(server.PREFIX + '/one-style.html');
await expect(page.locator('div')).toHaveText('hello, world!');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(255, 192, 203)');
expect(await response.text()).toContain(`<div>hello, world!</div>`);
});

View file

@ -37,7 +37,7 @@ test('should handle fixture timeout', async ({ runInlineTest }) => {
`
}, { timeout: 500 });
expect(result.exitCode).toBe(1);
expect(result.output).toContain('Test finished within timeout of 500ms, but tearing down "timeout" ran out of time.');
expect(result.output).toContain('Tearing down "timeout" exceeded the test timeout of 500ms.');
expect(result.failed).toBe(2);
});
@ -459,7 +459,7 @@ test('should not give enough time for second fixture teardown after timeout', as
}, { timeout: 2000 });
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.output).toContain('Test finished within timeout of 3000ms, but tearing down "fixture" ran out of time.');
expect(result.output).toContain('Tearing down "fixture" exceeded the test timeout of 3000ms.');
expect(result.outputLines).toEqual([
'teardown start',
'teardown finished',
@ -525,7 +525,7 @@ test('should not report fixture teardown timeout twice', async ({ runInlineTest
}, { reporter: 'list', timeout: 1000 });
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.output).toContain('Test finished within timeout of 1000ms, but tearing down "fixture" ran out of time.');
expect(result.output).toContain('Tearing down "fixture" exceeded the test timeout of 1000ms.');
expect(result.output).not.toContain('base.extend'); // Should not point to the location.
expect(result.output).not.toContain('Worker teardown timeout');
});
@ -730,7 +730,7 @@ test('should not continue with scope teardown after fixture teardown timeout', a
}, { reporter: 'list', timeout: 1000 });
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.output).toContain('Test finished within timeout of 1000ms, but tearing down "fixture2" ran out of time.');
expect(result.output).toContain('Tearing down "fixture2" exceeded the test timeout of 1000ms.');
expect(result.output).not.toContain('in fixture teardown');
});

View file

@ -16,6 +16,7 @@
import { test, expect, playwrightCtConfigText } from './playwright-test-fixtures';
import fs from 'fs';
import path from 'path';
test.describe.configure({ mode: 'parallel' });
@ -121,6 +122,28 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
await expect(component).toHaveText('Clashing name 2');
});
`,
'src/relative-import-different-folders/one/index.tsx': `
export default () => <button>Button</button>;
`,
'src/relative-import-different-folders/one/one.spec.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '.';
test('pass', async ({ mount }) => {
const component = await mount(<Button></Button>);
await expect(component).toHaveText('Button');
});
`,
'src/relative-import-different-folders/two/index.tsx': `
export default () => <button>Button</button>;
`,
'src/relative-import-different-folders/two/two.spec.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '.';
test('pass', async ({ mount }) => {
const component = await mount(<Button></Button>);
await expect(component).toHaveText('Button');
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
@ -158,6 +181,14 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
id: expect.stringContaining('defaultExport'),
importSource: expect.stringContaining('./defaultExport'),
filename: expect.stringContaining('default-import.spec.tsx'),
}, {
id: expect.stringContaining('_one'),
importSource: expect.stringContaining('.'),
filename: expect.stringContaining(`one${path.sep}one.spec.tsx`),
}, {
id: expect.stringContaining('_two'),
importSource: expect.stringContaining('.'),
filename: expect.stringContaining(`two${path.sep}two.spec.tsx`),
}]);
for (const [, value] of Object.entries(metainfo.deps))
@ -184,6 +215,14 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining('button.tsx'),
]],
[expect.stringContaining(`one${path.sep}index.tsx`), [
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining(`one${path.sep}index.tsx`),
]],
[expect.stringContaining(`two${path.sep}index.tsx`), [
expect.stringContaining('jsx-runtime.js'),
expect.stringContaining(`two${path.sep}index.tsx`),
]],
]);
});
@ -497,6 +536,24 @@ test('should render component via re-export', async ({ runInlineTest }, testInfo
expect(result.passed).toBe(1);
});
test('should import json', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightCtConfigText,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/some.json': `{ "some": "value" }`,
'src/button.test.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import json from './some.json';
test('pass', async ({}) => {
expect(json.some).toBe('value');
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should render component exported via fixture', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightCtConfigText,

View file

@ -1236,6 +1236,39 @@ test('preserve reportName on projects', async ({ runInlineTest, mergeReports })
expect(output).toContain(`botNames: first,second`);
});
test('keep projects with same name different bot name separate', async ({ runInlineTest, mergeReports, showReport, page }) => {
const files = (reportName: string) => ({
'playwright.config.ts': `
module.exports = {
reporter: [['blob', { fileName: '${reportName}.zip' }]],
projects: [
{ name: 'foo' },
]
};
`,
'a.test.js': `
import { test, expect } from '@playwright/test';
test('test 1', async ({}) => { expect('${reportName}').toBe('second'); });
`,
});
await runInlineTest(files('first'), undefined, { PWTEST_BOT_NAME: 'first' });
await runInlineTest(files('second'), undefined, { PWTEST_BOT_NAME: 'second', PWTEST_BLOB_DO_NOT_REMOVE: '1' });
const reportDir = test.info().outputPath('blob-report');
const { exitCode } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] });
expect(exitCode).toBe(0);
await showReport();
await expect(page.locator('.subnav-item:has-text("Passed") .counter')).toHaveText('1');
await expect(page.locator('.subnav-item:has-text("Failed") .counter')).toHaveText('1');
await page.getByText(/test 1.*first/).getByRole('link', { name: 'test' }).click();
await expect(page.getByText('Errors')).toBeVisible();
await page.goBack();
await page.getByText(/test 1.*second/).getByRole('link', { name: 'test' }).click();
await expect(page.getByText('Errors')).not.toBeVisible();
});
test('no reports error', async ({ runInlineTest, mergeReports }) => {
const reportDir = test.info().outputPath('blob-report');
fs.mkdirSync(reportDir, { recursive: true });

View file

@ -78,7 +78,14 @@ test('should ignore stdio when quiet', async ({ runInlineTest }) => {
expect(result.output).not.toContain('%%');
});
test('should support console colors', async ({ runInlineTest }) => {
test('should support console colors but not tty', {
annotation: [
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/15366' },
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29839' },
],
}, async ({ runInlineTest, nodeVersion }) => {
test.skip(nodeVersion.major < 18, 'Node16 does not respect FORCE_COLOR in onsole');
const result = await runInlineTest({
'a.spec.js': `
import { test, expect } from '@playwright/test';
@ -90,31 +97,13 @@ test('should support console colors', async ({ runInlineTest }) => {
});
`
});
expect(result.output).toContain(`process.stdout.isTTY = true`);
expect(result.output).toContain(`process.stderr.isTTY = true`);
expect(result.output).toContain(`process.stdout.isTTY = undefined`);
expect(result.output).toContain(`process.stderr.isTTY = undefined`);
// The output should have colors.
expect(result.rawOutput).toContain(`{ b: \x1b[33mtrue\x1b[39m, n: \x1b[33m123\x1b[39m, s: \x1b[32m'abc'\x1b[39m }`);
expect(result.rawOutput).toContain(`{ b: \x1b[33mfalse\x1b[39m, n: \x1b[33m123\x1b[39m, s: \x1b[32m'abc'\x1b[39m }`);
});
test('should override hasColors and getColorDepth', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.js': `
import { test, expect } from '@playwright/test';
test('console log', () => {
console.log('process.stdout.hasColors(1) = ' + process.stdout.hasColors(1));
console.log('process.stderr.hasColors(1) = ' + process.stderr.hasColors(1));
console.log('process.stdout.getColorDepth() > 0 = ' + (process.stdout.getColorDepth() > 0));
console.log('process.stderr.getColorDepth() > 0 = ' + (process.stderr.getColorDepth() > 0));
});
`
});
expect(result.output).toContain(`process.stdout.hasColors(1) = true`);
expect(result.output).toContain(`process.stderr.hasColors(1) = true`);
expect(result.output).toContain(`process.stdout.getColorDepth() > 0 = true`);
expect(result.output).toContain(`process.stderr.getColorDepth() > 0 = true`);
});
test('should not throw type error when using assert', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.js': `
@ -128,30 +117,3 @@ test('should not throw type error when using assert', async ({ runInlineTest })
expect(result.output).not.toContain(`TypeError: process.stderr.hasColors is not a function`);
expect(result.output).toContain(`AssertionError`);
});
test('should provide stubs for tty.WriteStream methods on process.stdout/stderr', async ({ runInlineTest }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29839' });
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
import type * as tty from 'tty';
async function checkMethods(stream: tty.WriteStream) {
expect(stream.isTTY).toBe(true);
await new Promise<void>(r => stream.clearLine(-1, r));;
await new Promise<void>(r => stream.clearScreenDown(r));;
await new Promise<void>(r => stream.cursorTo(0, 0, r));;
await new Promise<void>(r => stream.cursorTo(0, r));;
await new Promise<void>(r => stream.moveCursor(1, 1, r));;
expect(stream.getWindowSize()).toEqual([stream.columns, stream.rows]);
// getColorDepth() and hasColors() are covered in other tests.
}
test('process.stdout implementd tty.WriteStream methods', () => {
checkMethods(process.stdout);
});
test('process.stderr implementd tty.WriteStream methods', () => {
checkMethods(process.stderr);
});
`
});
expect(result.exitCode).toBe(0);
});

View file

@ -75,7 +75,7 @@ export function dumpTestTree(page: Page, options: { time?: boolean } = {}): () =
const indent = listItem.querySelectorAll('.list-view-indent').length;
const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : '';
const selected = listItem.classList.contains('selected') ? ' <=' : '';
const title = listItem.querySelector('.ui-mode-list-item-title').textContent;
const title = listItem.querySelector('.ui-mode-list-item-title').childNodes[0].textContent;
const timeElement = options.time ? listItem.querySelector('.ui-mode-list-item-time') : undefined;
const time = timeElement ? ' ' + timeElement.textContent.replace(/[.\d]+m?s/, 'XXms') : '';
result.push(' ' + ' '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + title + time + watch + selected);

View file

@ -56,6 +56,22 @@ test('should filter by explicit tags', async ({ runUITest }) => {
`);
});
test('should display native tags and filter by them on click', async ({ runUITest }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('p', () => {});
test('pwt', { tag: '@smoke' }, () => {});
`,
});
await page.locator('.ui-mode-list-item-title').getByText('smoke').click();
await expect(page.getByPlaceholder('Filter')).toHaveValue('@smoke');
await expect.poll(dumpTestTree(page)).toBe(`
a.test.ts
pwt
`);
});
test('should filter by status', async ({ runUITest }) => {
const { page } = await runUITest(basicTestTree);

View file

@ -50,8 +50,10 @@ test('should run global setup and teardown', async ({ runUITest }) => {
]);
});
test('should teardown on sigint', async ({ runUITest }) => {
test('should teardown on sigint', async ({ runUITest, nodeVersion }) => {
test.skip(process.platform === 'win32', 'No sending SIGINT on Windows');
test.skip(nodeVersion.major < 18);
const { page, testProcess } = await runUITest({
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';
@ -201,8 +203,9 @@ test('should run part of the setup only', async ({ runUITest }) => {
for (const useWeb of [true, false]) {
test.describe(`web-mode: ${useWeb}`, () => {
test('should run teardown with SIGINT', async ({ runUITest }) => {
test('should run teardown with SIGINT', async ({ runUITest, nodeVersion }) => {
test.skip(process.platform === 'win32', 'No sending SIGINT on Windows');
test.skip(nodeVersion.major < 18);
const { page, testProcess } = await runUITest({
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';

View file

@ -28,7 +28,29 @@ const basicTestTree = {
`
};
test('should stop on F6', async ({ runUITest }) => {
test('should run tests', async ({ runUITest }) => {
const { page } = await runUITest(basicTestTree);
await expect(page.getByTitle('Run all')).toBeEnabled();
await expect(page.getByTitle('Stop')).toBeDisabled();
await page.getByPlaceholder('Filter (e.g. text, @tag)').fill('test 3');
await page.keyboard.press('F5');
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)');
await page.getByPlaceholder('Filter (e.g. text, @tag)').fill('');
// Only the filtered test was run.
await expect.poll(dumpTestTree(page)).toBe(`
a.test.ts
test 0
test 1
test 2
test 3
`);
});
test('should stop tests', async ({ runUITest }) => {
const { page } = await runUITest(basicTestTree);
await expect(page.getByTitle('Run all')).toBeEnabled();
@ -47,7 +69,7 @@ test('should stop on F6', async ({ runUITest }) => {
await expect(page.getByTitle('Run all')).toBeDisabled();
await expect(page.getByTitle('Stop')).toBeEnabled();
await page.keyboard.press('F6');
await page.keyboard.press('Shift+F5');
await expect.poll(dumpTestTree(page)).toBe(`
a.test.ts
@ -58,12 +80,15 @@ test('should stop on F6', async ({ runUITest }) => {
`);
});
test('should reload on F5', async ({ runUITest }) => {
test('should toggle Terminal', async ({ runUITest }) => {
const { page } = await runUITest(basicTestTree);
await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)');
await expect(page.getByTitle('Run all')).toBeEnabled();
await expect(page.getByTitle('Stop')).toBeDisabled();
await page.keyboard.press('F5');
await expect(page.getByTestId('status-line')).toBeHidden();
});
await expect(page.getByTestId('output')).toBeHidden();
await page.keyboard.press(process.platform === 'darwin' ? 'Control+Backquote' : 'Control+Shift+Backquote');
await expect(page.getByTestId('output')).toBeVisible();
});