Merge branch 'main' into aria-snapshots

Signed-off-by: Debbie O'Brien <debs-obrien@users.noreply.github.com>
This commit is contained in:
Debbie O'Brien 2025-01-29 20:53:28 +01:00 committed by GitHub
commit 649acf6f11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1238 additions and 806 deletions

View file

@ -3,9 +3,21 @@ name: Roll Browser into Playwright
on: on:
repository_dispatch: repository_dispatch:
types: [roll_into_pw] types: [roll_into_pw]
workflow_dispatch:
inputs:
browser:
description: 'Browser name, e.g. chromium'
required: true
type: string
revision:
description: 'Browser revision without v prefix, e.g. 1234'
required: true
type: string
env: env:
ELECTRON_SKIP_BINARY_DOWNLOAD: 1 ELECTRON_SKIP_BINARY_DOWNLOAD: 1
BROWSER: ${{ github.event.client_payload.browser || github.event.inputs.browser }}
REVISION: ${{ github.event.client_payload.revision || github.event.inputs.revision }}
permissions: permissions:
contents: write contents: write
@ -24,19 +36,19 @@ jobs:
run: npx playwright install-deps run: npx playwright install-deps
- name: Roll to new revision - name: Roll to new revision
run: | run: |
./utils/roll_browser.js ${{ github.event.client_payload.browser }} ${{ github.event.client_payload.revision }} ./utils/roll_browser.js $BROWSER $REVISION
npm run build npm run build
- name: Prepare branch - name: Prepare branch
id: prepare-branch id: prepare-branch
run: | run: |
BRANCH_NAME="roll-into-pw-${{ github.event.client_payload.browser }}/${{ github.event.client_payload.revision }}" BRANCH_NAME="roll-into-pw-${BROWSER}/${REVISION}"
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT
git config --global user.name github-actions git config --global user.name github-actions
git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com
git checkout -b "$BRANCH_NAME" git checkout -b "$BRANCH_NAME"
git add . git add .
git commit -m "feat(${{ github.event.client_payload.browser }}): roll to r${{ github.event.client_payload.revision }}" git commit -m "feat(${BROWSER}): roll to r${REVISION}"
git push origin $BRANCH_NAME git push origin $BRANCH_NAME --force
- name: Create Pull Request - name: Create Pull Request
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
@ -47,7 +59,7 @@ jobs:
repo: 'playwright', repo: 'playwright',
head: 'microsoft:${{ steps.prepare-branch.outputs.BRANCH_NAME }}', head: 'microsoft:${{ steps.prepare-branch.outputs.BRANCH_NAME }}',
base: 'main', base: 'main',
title: 'feat(${{ github.event.client_payload.browser }}): roll to r${{ github.event.client_payload.revision }}', title: 'feat(${{ env.BROWSER }}): roll to r${{ env.REVISION }}',
}); });
await github.rest.issues.addLabels({ await github.rest.issues.addLabels({
owner: 'microsoft', owner: 'microsoft',

View file

@ -2245,12 +2245,13 @@ assertThat(page.locator("body")).matchesAriaSnapshot("""
Asserts that the target element matches the given [accessibility snapshot](../aria-snapshots.md). Asserts that the target element matches the given [accessibility snapshot](../aria-snapshots.md).
Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file.
**Usage** **Usage**
```js ```js
await expect(page.locator('body')).toMatchAriaSnapshot(); await expect(page.locator('body')).toMatchAriaSnapshot();
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot.yml' }); await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' });
``` ```
### option: LocatorAssertions.toMatchAriaSnapshot#2.name ### option: LocatorAssertions.toMatchAriaSnapshot#2.name
@ -2258,7 +2259,7 @@ await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot.yml' })
* langs: js * langs: js
- `name` <[string]> - `name` <[string]>
Name of the snapshot to store in the snapshot (screenshot) folder corresponding to this test. Name of the snapshot to store in the snapshot folder corresponding to this test.
Generates sequential names if not specified. Generates sequential names if not specified.
### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-js-assertions-timeout-%% ### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-js-assertions-timeout-%%

View file

@ -1758,7 +1758,9 @@ await Expect(Page.GetByTitle("Issues count")).toHaveText("25 issues");
- `type` ?<[string]> - `type` ?<[string]>
* langs: js * langs: js
This option configures a template controlling location of snapshots generated by [`method: PageAssertions.toHaveScreenshot#1`] and [`method: SnapshotAssertions.toMatchSnapshot#1`]. This option configures a template controlling location of snapshots generated by [`method: PageAssertions.toHaveScreenshot#1`], [`method: LocatorAssertions.toMatchAriaSnapshot#2`] and [`method: SnapshotAssertions.toMatchSnapshot#1`].
You can configure templates for each assertion separately in [`property: TestConfig.expect`].
**Usage** **Usage**
@ -1767,7 +1769,19 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './tests', testDir: './tests',
// Single template for all assertions
snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',
// Assertion-specific templates
expect: {
toHaveScreenshot: {
pathTemplate: '{testDir}/__screenshots__{/projectName}/{testFilePath}/{arg}{ext}',
},
toMatchAriaSnapshot: {
pathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}',
},
},
}); });
``` ```
@ -1798,22 +1812,22 @@ test.describe('suite', () => {
The list of supported tokens: The list of supported tokens:
* `{arg}` - Relative snapshot path **without extension**. These come from the arguments passed to the `toHaveScreenshot()` and `toMatchSnapshot()` calls; if called without arguments, this will be an auto-generated snapshot name. * `{arg}` - Relative snapshot path **without extension**. This comes from the arguments passed to `toHaveScreenshot()`, `toMatchAriaSnapshot()` or `toMatchSnapshot()`; if called without arguments, this will be an auto-generated snapshot name.
* Value: `foo/bar/baz` * Value: `foo/bar/baz`
* `{ext}` - snapshot extension (with dots) * `{ext}` - Snapshot extension (with the leading dot).
* Value: `.png` * Value: `.png`
* `{platform}` - The value of `process.platform`. * `{platform}` - The value of `process.platform`.
* `{projectName}` - Project's file-system-sanitized name, if any. * `{projectName}` - Project's file-system-sanitized name, if any.
* Value: `''` (empty string). * Value: `''` (empty string).
* `{snapshotDir}` - Project's [`property: TestConfig.snapshotDir`]. * `{snapshotDir}` - Project's [`property: TestProject.snapshotDir`].
* Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`) * Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`)
* `{testDir}` - Project's [`property: TestConfig.testDir`]. * `{testDir}` - Project's [`property: TestProject.testDir`].
* Value: `/home/playwright/tests` (absolute path is since `testDir` is resolved relative to directory with config) * Value: `/home/playwright/tests` (absolute path since `testDir` is resolved relative to directory with config)
* `{testFileDir}` - Directories in relative path from `testDir` to **test file**. * `{testFileDir}` - Directories in relative path from `testDir` to **test file**.
* Value: `page` * Value: `page`
* `{testFileName}` - Test file name with extension. * `{testFileName}` - Test file name with extension.
* Value: `page-click.spec.ts` * Value: `page-click.spec.ts`
* `{testFilePath}` - Relative path from `testDir` to **test file** * `{testFilePath}` - Relative path from `testDir` to **test file**.
* Value: `page/page-click.spec.ts` * Value: `page/page-click.spec.ts`
* `{testName}` - File-system-sanitized test title, including parent describes but excluding file name. * `{testName}` - File-system-sanitized test title, including parent describes but excluding file name.
* Value: `suite-test-should-work` * Value: `suite-test-should-work`

View file

@ -232,7 +232,7 @@ await page.goto('https://github.com/login')
# Interact with login form # Interact with login form
await page.get_by_label("Username or email address").fill("username") await page.get_by_label("Username or email address").fill("username")
await page.get_by_label("Password").fill("password") await page.get_by_label("Password").fill("password")
await page.page.get_by_role("button", name="Sign in").click() await page.get_by_role("button", name="Sign in").click()
# Continue with the test # Continue with the test
``` ```

View file

@ -21,7 +21,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
``` ```
* New method [`method: Test.step.skip`] to disable execution of a test step. * New method [`method: Test.step.skip`] to disable execution of a test step.
```js ```js
test('some test', async ({ page }) => { test('some test', async ({ page }) => {
await test.step('before running step', async () => { await test.step('before running step', async () => {
@ -49,6 +49,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
* Option [`property: TestConfig.webServer`] added a `gracefulShutdown` field for specifying a process kill signal other than the default `SIGKILL`. * Option [`property: TestConfig.webServer`] added a `gracefulShutdown` field for specifying a process kill signal other than the default `SIGKILL`.
* Exposed [`property: TestStep.attachments`] from the reporter API to allow retrieval of all attachments created by that step. * Exposed [`property: TestStep.attachments`] from the reporter API to allow retrieval of all attachments created by that step.
* New option `pathTemplate` for `toHaveScreenshot` and `toMatchAriaSnapshot` assertions in the [`property: TestConfig.expect`] configuration.
### UI updates ### UI updates

View file

@ -48,6 +48,9 @@ export default defineConfig({
- `scale` ?<[ScreenshotScale]<"css"|"device">> See [`option: Page.screenshot.scale`] in [`method: Page.screenshot`]. Defaults to `"css"`. - `scale` ?<[ScreenshotScale]<"css"|"device">> See [`option: Page.screenshot.scale`] in [`method: Page.screenshot`]. Defaults to `"css"`.
- `stylePath` ?<[string]|[Array]<[string]>> See [`option: Page.screenshot.style`] in [`method: Page.screenshot`]. - `stylePath` ?<[string]|[Array]<[string]>> See [`option: Page.screenshot.style`] in [`method: Page.screenshot`].
- `threshold` ?<[float]> An acceptable perceived color difference between the same pixel in compared images, ranging from `0` (strict) and `1` (lax). `"pixelmatch"` comparator computes color difference in [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`. - `threshold` ?<[float]> An acceptable perceived color difference between the same pixel in compared images, ranging from `0` (strict) and `1` (lax). `"pixelmatch"` comparator computes color difference in [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`.
- `pathTemplate` ?<[string]> A template controlling location of the screenshots. See [`property: TestConfig.snapshotPathTemplate`] for details.
- `toMatchAriaSnapshot` ?<[Object]> Configuration for the [`method: LocatorAssertions.toMatchAriaSnapshot#2`] method.
- `pathTemplate` ?<[string]> A template controlling location of the aria snapshots. See [`property: TestConfig.snapshotPathTemplate`] for details.
- `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method. - `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method.
- `maxDiffPixels` ?<[int]> An acceptable amount of pixels that could be different, unset by default. - `maxDiffPixels` ?<[int]> An acceptable amount of pixels that could be different, unset by default.
- `maxDiffPixelRatio` ?<[float]> An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1` , unset by default. - `maxDiffPixelRatio` ?<[float]> An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1` , unset by default.
@ -234,7 +237,9 @@ export default defineConfig({
* since: v1.10 * since: v1.10
- type: ?<[Metadata]> - type: ?<[Metadata]>
Metadata that will be put directly to the test report serialized as JSON. Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json.
See also [`property: TestConfig.populateGitInfo`] that populates metadata.
**Usage** **Usage**
@ -242,7 +247,7 @@ Metadata that will be put directly to the test report serialized as JSON.
import { defineConfig } from '@playwright/test'; import { defineConfig } from '@playwright/test';
export default defineConfig({ export default defineConfig({
metadata: 'acceptance tests', metadata: { title: 'acceptance tests' },
}); });
``` ```
@ -321,6 +326,24 @@ This path will serve as the base directory for each test file snapshot directory
## property: TestConfig.snapshotPathTemplate = %%-test-config-snapshot-path-template-%% ## property: TestConfig.snapshotPathTemplate = %%-test-config-snapshot-path-template-%%
* since: v1.28 * since: v1.28
## property: TestConfig.populateGitInfo
* since: v1.51
- type: ?<[boolean]>
Whether to populate `'git.commit.info'` field of the [`property: TestConfig.metadata`] with Git commit info and CI/CD information.
This information will appear in the HTML and JSON reports and is available in the Reporter API.
**Usage**
```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';
export default defineConfig({
populateGitInfo: !!process.env.CI,
});
```
## property: TestConfig.preserveOutput ## property: TestConfig.preserveOutput
* since: v1.10 * since: v1.10
- type: ?<[PreserveOutput]<"always"|"never"|"failures-only">> - type: ?<[PreserveOutput]<"always"|"never"|"failures-only">>
@ -631,7 +654,7 @@ export default defineConfig({
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
- `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`. - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`.
- `signal` <["SIGINT"|"SIGTERM"]> - `signal` <["SIGINT"|"SIGTERM"]>
- `timeout` <[int]> - `timeout` <[int]>
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified. - `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified.
Launch a development web server (or multiple) during the tests. Launch a development web server (or multiple) during the tests.

View file

@ -98,6 +98,9 @@ export default defineConfig({
- `caret` ?<[ScreenshotCaret]<"hide"|"initial">> See [`option: Page.screenshot.caret`] in [`method: Page.screenshot`]. Defaults to `"hide"`. - `caret` ?<[ScreenshotCaret]<"hide"|"initial">> See [`option: Page.screenshot.caret`] in [`method: Page.screenshot`]. Defaults to `"hide"`.
- `scale` ?<[ScreenshotScale]<"css"|"device">> See [`option: Page.screenshot.scale`] in [`method: Page.screenshot`]. Defaults to `"css"`. - `scale` ?<[ScreenshotScale]<"css"|"device">> See [`option: Page.screenshot.scale`] in [`method: Page.screenshot`]. Defaults to `"css"`.
- `stylePath` ?<[string]|[Array]<[string]>> See [`option: Page.screenshot.style`] in [`method: Page.screenshot`]. - `stylePath` ?<[string]|[Array]<[string]>> See [`option: Page.screenshot.style`] in [`method: Page.screenshot`].
- `pathTemplate` ?<[string]> A template controlling location of the screenshots. See [`property: TestProject.snapshotPathTemplate`] for details.
- `toMatchAriaSnapshot` ?<[Object]> Configuration for the [`method: LocatorAssertions.toMatchAriaSnapshot#2`] method.
- `pathTemplate` ?<[string]> A template controlling location of the aria snapshots. See [`property: TestProject.snapshotPathTemplate`] for details.
- `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method. - `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method.
- `threshold` ?<[float]> an acceptable perceived color difference between the same pixel in compared images, ranging from `0` (strict) and `1` (lax). `"pixelmatch"` comparator computes color difference in [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`. - `threshold` ?<[float]> an acceptable perceived color difference between the same pixel in compared images, ranging from `0` (strict) and `1` (lax). `"pixelmatch"` comparator computes color difference in [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`.
- `maxDiffPixels` ?<[int]> an acceptable amount of pixels that could be different, unset by default. - `maxDiffPixels` ?<[int]> an acceptable amount of pixels that could be different, unset by default.

View file

@ -48,7 +48,7 @@ The snapshot name `example-test-1-chromium-darwin.png` consists of a few parts:
- `chromium-darwin` - the browser name and the platform. Screenshots differ between browsers and platforms due to different rendering, fonts and more, so you will need different snapshots for them. If you use multiple projects in your [configuration file](./test-configuration.md), project name will be used instead of `chromium`. - `chromium-darwin` - the browser name and the platform. Screenshots differ between browsers and platforms due to different rendering, fonts and more, so you will need different snapshots for them. If you use multiple projects in your [configuration file](./test-configuration.md), project name will be used instead of `chromium`.
The snapshot name and path can be configured with [`snapshotPathTemplate`](./api/class-testproject#test-project-snapshot-path-template) in the playwright config. The snapshot name and path can be configured with [`property: TestConfig.snapshotPathTemplate`] in the playwright config.
## Updating screenshots ## Updating screenshots

View file

@ -69,22 +69,6 @@ export const blank = () => {
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>; return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
}; };
export const externalLink = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16'><path fillRule='evenodd' d='M10.604 1h4.146a.25.25 0 01.25.25v4.146a.25.25 0 01-.427.177L13.03 4.03 9.28 7.78a.75.75 0 01-1.06-1.06l3.75-3.75-1.543-1.543A.25.25 0 0110.604 1zM3.75 2A1.75 1.75 0 002 3.75v8.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 12.25v-3.5a.75.75 0 00-1.5 0v3.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25h3.5a.75.75 0 000-1.5h-3.5z'></path></svg>;
};
export const calendar = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16'><path fillRule='evenodd' d='M4.75 0a.75.75 0 01.75.75V2h5V.75a.75.75 0 011.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0113.25 16H2.75A1.75 1.75 0 011 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 014.75 0zm0 3.5h8.5a.25.25 0 01.25.25V6h-11V3.75a.25.25 0 01.25-.25h2zm-2.25 4v6.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V7.5h-11z'></path></svg>;
};
export const person = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16'><path fillRule='evenodd' d='M10.5 5a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm.061 3.073a4 4 0 10-5.123 0 6.004 6.004 0 00-3.431 5.142.75.75 0 001.498.07 4.5 4.5 0 018.99 0 .75.75 0 101.498-.07 6.005 6.005 0 00-3.432-5.142z'></path></svg>;
};
export const commit = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16'><path fillRule='evenodd' d='M10.5 7.75a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm1.43.75a4.002 4.002 0 01-7.86 0H.75a.75.75 0 110-1.5h3.32a4.001 4.001 0 017.86 0h3.32a.75.75 0 110 1.5h-3.32z'></path></svg>;
};
export const image = () => { export const image = () => {
return <svg className='octicon' viewBox='0 0 48 48' version='1.1' width='20' height='20' aria-hidden='true'> return <svg className='octicon' viewBox='0 0 48 48' version='1.1' width='20' height='20' aria-hidden='true'>
<path xmlns='http://www.w3.org/2000/svg' d='M11.85 32H36.2l-7.35-9.95-6.55 8.7-4.6-6.45ZM7 40q-1.2 0-2.1-.9Q4 38.2 4 37V11q0-1.2.9-2.1Q5.8 8 7 8h34q1.2 0 2.1.9.9.9.9 2.1v26q0 1.2-.9 2.1-.9.9-2.1.9Zm0-29v26-26Zm34 26V11H7v26Z'/> <path xmlns='http://www.w3.org/2000/svg' d='M11.85 32H36.2l-7.35-9.95-6.55 8.7-4.6-6.45ZM7 40q-1.2 0-2.1-.9Q4 38.2 4 37V11q0-1.2.9-2.1Q5.8 8 7 8h34q1.2 0 2.1.9.9.9.9 2.1v26q0 1.2-.9 2.1-.9.9-2.1.9Zm0-29v26-26Zm34 26V11H7v26Z'/>

View file

@ -0,0 +1,41 @@
/*
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.
*/
.metadata-toggle {
cursor: pointer;
user-select: none;
margin-left: 5px;
}
.metadata-view {
border: 1px solid var(--color-border-default);
border-radius: 6px;
margin-top: 8px;
}
.metadata-separator {
height: 1px;
border-bottom: 1px solid var(--color-border-default);
}
.metadata-view .copy-value-container {
margin-top: -2px;
}
.git-commit-info a {
color: var(--color-fg-default);
font-weight: 600;
}

View file

@ -17,21 +17,19 @@
import * as React from 'react'; import * as React from 'react';
import './colors.css'; import './colors.css';
import './common.css'; import './common.css';
import * as icons from './icons';
import { AutoChip } from './chip';
import './reportView.css';
import './theme.css'; import './theme.css';
import './metadataView.css';
import type { Metadata } from '@playwright/test';
import type { GitCommitInfo } from '@testIsomorphic/types';
import { CopyToClipboardContainer } from './copyToClipboard';
import { linkifyText } from '@web/renderUtils';
export type Metainfo = { type MetadataEntries = [string, unknown][];
'revision.id'?: string;
'revision.author'?: string; export function filterMetadata(metadata: Metadata): MetadataEntries {
'revision.email'?: string; // TODO: do not plumb actualWorkers through metadata.
'revision.subject'?: string; return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
'revision.timestamp'?: number | Date; }
'revision.link'?: string;
'ci.link'?: string;
'timestamp'?: number
};
class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> { class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> {
override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = { override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = {
@ -46,12 +44,12 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
override render() { override render() {
if (this.state.error || this.state.errorInfo) { if (this.state.error || this.state.errorInfo) {
return ( return (
<AutoChip header={'Commit Metainfo Error'} dataTestId='metadata-error'> <div className='metadata-view p-3'>
<p>An error was encountered when trying to render Commit Metainfo. Please file a GitHub issue to report this error.</p> <p>An error was encountered when trying to render metadata.</p>
<p> <p>
<pre style={{ overflow: 'scroll' }}>{this.state.error?.message}<br/>{this.state.error?.stack}<br/>{this.state.errorInfo?.componentStack}</pre> <pre style={{ overflow: 'scroll' }}>{this.state.error?.message}<br/>{this.state.error?.stack}<br/>{this.state.errorInfo?.componentStack}</pre>
</p> </p>
</AutoChip> </div>
); );
} }
@ -59,79 +57,50 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
} }
} }
export const MetadataView: React.FC<Metainfo> = metadata => <ErrorBoundary><InnerMetadataView {...metadata} /></ErrorBoundary>; export const MetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
return <ErrorBoundary><InnerMetadataView metadataEntries={metadataEntries}/></ErrorBoundary>;
const InnerMetadataView: React.FC<Metainfo> = metadata => {
if (!Object.keys(metadata).find(k => k.startsWith('revision.') || k.startsWith('ci.')))
return null;
return (
<AutoChip header={
<span>
{metadata['revision.id'] && <span style={{ float: 'right' }}>
{metadata['revision.id'].slice(0, 7)}
</span>}
{metadata['revision.subject'] || 'Commit Metainfo'}
</span>} initialExpanded={false} dataTestId='metadata-chip'>
{metadata['revision.subject'] &&
<MetadataViewItem
testId='revision.subject'
content={<span>{metadata['revision.subject']}</span>}
/>
}
{metadata['revision.id'] &&
<MetadataViewItem
testId='revision.id'
content={<span>{metadata['revision.id']}</span>}
href={metadata['revision.link']}
icon='commit'
/>
}
{(metadata['revision.author'] || metadata['revision.email']) &&
<MetadataViewItem
content={`${metadata['revision.author']} ${metadata['revision.email']}`}
icon='person'
/>
}
{metadata['revision.timestamp'] &&
<MetadataViewItem
testId='revision.timestamp'
content={
<>
{Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).format(metadata['revision.timestamp'])}
{' '}
{Intl.DateTimeFormat(undefined, { timeStyle: 'long' }).format(metadata['revision.timestamp'])}
</>
}
icon='calendar'
/>
}
{metadata['ci.link'] &&
<MetadataViewItem
content='CI/CD Logs'
href={metadata['ci.link']}
icon='externalLink'
/>
}
{metadata['timestamp'] &&
<MetadataViewItem
content={<span style={{ color: 'var(--color-fg-subtle)' }}>
Report generated on {Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(metadata['timestamp'])}
</span>}></MetadataViewItem>
}
</AutoChip>
);
}; };
const MetadataViewItem: React.FC<{ content: JSX.Element | string; icon?: keyof typeof icons, href?: string, testId?: string }> = ({ content, icon, href, testId }) => { const InnerMetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
return ( const gitCommitInfo = metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined;
<div className='my-1 hbox' data-testid={testId} > const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info');
<div className='mr-2'> if (!gitCommitInfo && !entries.length)
{icons[icon || 'blank']()} return null;
</div> return <div className='metadata-view'>
<div style={{ flex: 1 }}> {gitCommitInfo && <>
{href ? <a href={href} target='_blank' rel='noopener noreferrer'>{content}</a> : content} <GitCommitInfoView info={gitCommitInfo}/>
{entries.length > 0 && <div className='metadata-separator' />}
</>}
{entries.map(([key, value]) => {
const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
return <div className='m-1 ml-5' key={key}>
<span style={{ fontWeight: 'bold' }} title={key}>{key}</span>
{valueString && <CopyToClipboardContainer value={valueString}>: <span title={trimmedValue}>{linkifyText(trimmedValue)}</span></CopyToClipboardContainer>}
</div>;
})}
</div>;
};
const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
const email = info['revision.email'] ? ` <${info['revision.email']}>` : '';
const author = `${info['revision.author'] || ''}${email}`;
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info['revision.timestamp']);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info['revision.timestamp']);
return <div className='hbox pl-4 pr-2 git-commit-info' style={{ alignItems: 'center' }}>
<div className='vbox'>
<a className='m-2' href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
<span title={info['revision.subject'] || ''}>{info['revision.subject'] || ''}</span>
</a>
<div className='hbox m-2 mt-1'>
<div className='mr-1'>{author}</div>
<div title={longTimestamp}> on {shortTimestamp}</div>
{info['ci.link'] && <><span className='mx-2'>·</span><a href={info['ci.link']} target='_blank' rel='noopener noreferrer' title='CI/CD logs'>logs</a></>}
</div> </div>
</div> </div>
); {!!info['revision.link'] && <a href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
<span title='View commit details'>{info['revision.id']?.slice(0, 7) || 'unknown'}</span>
</a>}
{!info['revision.link'] && !!info['revision.id'] && <span>{info['revision.id'].slice(0, 7)}</span>}
</div>;
}; };

View file

@ -23,8 +23,6 @@ import { HeaderView } from './headerView';
import { Route, SearchParamsContext } from './links'; import { Route, SearchParamsContext } from './links';
import type { LoadedReport } from './loadedReport'; import type { LoadedReport } from './loadedReport';
import './reportView.css'; import './reportView.css';
import type { Metainfo } from './metadataView';
import { MetadataView } from './metadataView';
import { TestCaseView } from './testCaseView'; import { TestCaseView } from './testCaseView';
import { TestFilesHeader, TestFilesView } from './testFilesView'; import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css'; import './theme.css';
@ -50,6 +48,7 @@ export const ReportView: React.FC<{
const searchParams = React.useContext(SearchParamsContext); const searchParams = React.useContext(SearchParamsContext);
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map()); const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
const [filterText, setFilterText] = React.useState(searchParams.get('q') || ''); const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
const [metadataVisible, setMetadataVisible] = React.useState(false);
const testIdToFileIdMap = React.useMemo(() => { const testIdToFileIdMap = React.useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@ -76,9 +75,8 @@ export const ReportView: React.FC<{
return <div className='htmlreport vbox px-4 pb-4'> return <div className='htmlreport vbox px-4 pb-4'>
<main> <main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>} {report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
<Route predicate={testFilesRoutePredicate}> <Route predicate={testFilesRoutePredicate}>
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} /> <TestFilesHeader report={report?.json()} filteredStats={filteredStats} metadataVisible={metadataVisible} toggleMetadataVisible={() => setMetadataVisible(visible => !visible)}/>
<TestFilesView <TestFilesView
tests={filteredTests.files} tests={filteredTests.files}
expandedFiles={expandedFiles} expandedFiles={expandedFiles}

View file

@ -21,6 +21,8 @@ import './testFileView.css';
import { msToString } from './utils'; import { msToString } from './utils';
import { AutoChip } from './chip'; import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView'; import { TestErrorView } from './testErrorView';
import * as icons from './icons';
import { filterMetadata, MetadataView } from './metadataView';
export const TestFilesView: React.FC<{ export const TestFilesView: React.FC<{
tests: TestFileSummary[], tests: TestFileSummary[],
@ -62,17 +64,24 @@ export const TestFilesView: React.FC<{
export const TestFilesHeader: React.FC<{ export const TestFilesHeader: React.FC<{
report: HTMLReport | undefined, report: HTMLReport | undefined,
filteredStats?: FilteredStats, filteredStats?: FilteredStats,
}> = ({ report, filteredStats }) => { metadataVisible: boolean,
toggleMetadataVisible: () => void,
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
if (!report) if (!report)
return; return;
const metadataEntries = filterMetadata(report.metadata || {});
return <> return <>
<div className='mt-2 mx-1' style={{ display: 'flex' }}> <div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
{metadataEntries.length > 0 && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
</div>}
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>} {report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>}
{filteredStats && <div data-testid='filtered-tests-count' style={{ color: 'var(--color-fg-subtle)', padding: '0 10px' }}>Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}</div>} {filteredStats && <div data-testid='filtered-tests-count' style={{ color: 'var(--color-fg-subtle)', padding: '0 10px' }}>Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}</div>}
<div style={{ flex: 'auto' }}></div> <div style={{ flex: 'auto' }}></div>
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div> <div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div> <div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
</div> </div>
{metadataVisible && <MetadataView metadataEntries={metadataEntries}/>}
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'> {!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)} {report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>} </AutoChip>}

View file

@ -20,6 +20,7 @@
"@protocol/*": ["../protocol/src/*"], "@protocol/*": ["../protocol/src/*"],
"@web/*": ["../web/src/*"], "@web/*": ["../web/src/*"],
"@playwright/*": ["../playwright/src/*"], "@playwright/*": ["../playwright/src/*"],
"@testIsomorphic/*": ["../playwright/src/isomorphic/*"],
"playwright-core/lib/*": ["../playwright-core/src/*"], "playwright-core/lib/*": ["../playwright-core/src/*"],
"playwright/lib/*": ["../playwright/src/*"], "playwright/lib/*": ["../playwright/src/*"],
} }

View file

@ -6,7 +6,7 @@ This project incorporates components from the projects listed below. The origina
- @types/node@17.0.24 (https://github.com/DefinitelyTyped/DefinitelyTyped) - @types/node@17.0.24 (https://github.com/DefinitelyTyped/DefinitelyTyped)
- @types/yauzl@2.10.0 (https://github.com/DefinitelyTyped/DefinitelyTyped) - @types/yauzl@2.10.0 (https://github.com/DefinitelyTyped/DefinitelyTyped)
- agent-base@7.1.3 (https://github.com/TooTallNate/proxy-agents) - agent-base@6.0.2 (https://github.com/TooTallNate/node-agent-base)
- balanced-match@1.0.2 (https://github.com/juliangruber/balanced-match) - balanced-match@1.0.2 (https://github.com/juliangruber/balanced-match)
- brace-expansion@1.1.11 (https://github.com/juliangruber/brace-expansion) - brace-expansion@1.1.11 (https://github.com/juliangruber/brace-expansion)
- buffer-crc32@0.2.13 (https://github.com/brianloveswords/buffer-crc32) - buffer-crc32@0.2.13 (https://github.com/brianloveswords/buffer-crc32)
@ -24,7 +24,7 @@ This project incorporates components from the projects listed below. The origina
- fd-slicer@1.1.0 (https://github.com/andrewrk/node-fd-slicer) - fd-slicer@1.1.0 (https://github.com/andrewrk/node-fd-slicer)
- get-stream@5.2.0 (https://github.com/sindresorhus/get-stream) - get-stream@5.2.0 (https://github.com/sindresorhus/get-stream)
- graceful-fs@4.2.10 (https://github.com/isaacs/node-graceful-fs) - graceful-fs@4.2.10 (https://github.com/isaacs/node-graceful-fs)
- https-proxy-agent@7.0.6 (https://github.com/TooTallNate/proxy-agents) - https-proxy-agent@5.0.1 (https://github.com/TooTallNate/node-https-proxy-agent)
- ip-address@9.0.5 (https://github.com/beaugunderson/ip-address) - ip-address@9.0.5 (https://github.com/beaugunderson/ip-address)
- is-docker@2.2.1 (https://github.com/sindresorhus/is-docker) - is-docker@2.2.1 (https://github.com/sindresorhus/is-docker)
- is-wsl@2.2.0 (https://github.com/sindresorhus/is-wsl) - is-wsl@2.2.0 (https://github.com/sindresorhus/is-wsl)
@ -43,7 +43,7 @@ This project incorporates components from the projects listed below. The origina
- retry@0.12.0 (https://github.com/tim-kos/node-retry) - retry@0.12.0 (https://github.com/tim-kos/node-retry)
- signal-exit@3.0.7 (https://github.com/tapjs/signal-exit) - signal-exit@3.0.7 (https://github.com/tapjs/signal-exit)
- smart-buffer@4.2.0 (https://github.com/JoshGlazebrook/smart-buffer) - smart-buffer@4.2.0 (https://github.com/JoshGlazebrook/smart-buffer)
- socks-proxy-agent@8.0.5 (https://github.com/TooTallNate/proxy-agents) - socks-proxy-agent@6.1.1 (https://github.com/TooTallNate/node-socks-proxy-agent)
- socks@2.8.3 (https://github.com/JoshGlazebrook/socks) - socks@2.8.3 (https://github.com/JoshGlazebrook/socks)
- sprintf-js@1.1.3 (https://github.com/alexei/sprintf.js) - sprintf-js@1.1.3 (https://github.com/alexei/sprintf.js)
- stack-utils@2.0.5 (https://github.com/tapjs/stack-utils) - stack-utils@2.0.5 (https://github.com/tapjs/stack-utils)
@ -105,11 +105,128 @@ MIT License
========================================= =========================================
END OF @types/yauzl@2.10.0 AND INFORMATION END OF @types/yauzl@2.10.0 AND INFORMATION
%% agent-base@7.1.3 NOTICES AND INFORMATION BEGIN HERE %% agent-base@6.0.2 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
agent-base
==========
### Turn a function into an [`http.Agent`][http.Agent] instance
[![Build Status](https://github.com/TooTallNate/node-agent-base/workflows/Node%20CI/badge.svg)](https://github.com/TooTallNate/node-agent-base/actions?workflow=Node+CI)
This module provides an `http.Agent` generator. That is, you pass it an async
callback function, and it returns a new `http.Agent` instance that will invoke the
given callback function when sending outbound HTTP requests.
#### Some subclasses:
Here's some more interesting uses of `agent-base`.
Send a pull request to list yours!
* [`http-proxy-agent`][http-proxy-agent]: An HTTP(s) proxy `http.Agent` implementation for HTTP endpoints
* [`https-proxy-agent`][https-proxy-agent]: An HTTP(s) proxy `http.Agent` implementation for HTTPS endpoints
* [`pac-proxy-agent`][pac-proxy-agent]: A PAC file proxy `http.Agent` implementation for HTTP and HTTPS
* [`socks-proxy-agent`][socks-proxy-agent]: A SOCKS proxy `http.Agent` implementation for HTTP and HTTPS
Installation
------------
Install with `npm`:
``` bash
$ npm install agent-base
```
Example
-------
Here's a minimal example that creates a new `net.Socket` connection to the server
for every HTTP request (i.e. the equivalent of `agent: false` option):
```js
var net = require('net');
var tls = require('tls');
var url = require('url');
var http = require('http');
var agent = require('agent-base');
var endpoint = 'http://nodejs.org/api/';
var parsed = url.parse(endpoint);
// This is the important part!
parsed.agent = agent(function (req, opts) {
var socket;
// `secureEndpoint` is true when using the https module
if (opts.secureEndpoint) {
socket = tls.connect(opts);
} else {
socket = net.connect(opts);
}
return socket;
});
// Everything else works just like normal...
http.get(parsed, function (res) {
console.log('"response" event!', res.headers);
res.pipe(process.stdout);
});
```
Returning a Promise or using an `async` function is also supported:
```js
agent(async function (req, opts) {
await sleep(1000);
// etc…
});
```
Return another `http.Agent` instance to "pass through" the responsibility
for that HTTP request to that agent:
```js
agent(function (req, opts) {
return opts.secureEndpoint ? https.globalAgent : http.globalAgent;
});
```
API
---
## Agent(Function callback[, Object options]) → [http.Agent][]
Creates a base `http.Agent` that will execute the callback function `callback`
for every HTTP request that it is used as the `agent` for. The callback function
is responsible for creating a `stream.Duplex` instance of some kind that will be
used as the underlying socket in the HTTP request.
The `options` object accepts the following properties:
* `timeout` - Number - Timeout for the `callback()` function in milliseconds. Defaults to Infinity (optional).
The callback function should have the following signature:
### callback(http.ClientRequest req, Object options, Function cb) → undefined
The ClientRequest `req` can be accessed to read request headers and
and the path, etc. The `options` object contains the options passed
to the `http.request()`/`https.request()` function call, and is formatted
to be directly passed to `net.connect()`/`tls.connect()`, or however
else you want a Socket to be created. Pass the created socket to
the callback function `cb` once created, and the HTTP request will
continue to proceed.
If the `https` module is used to invoke the HTTP request, then the
`secureEndpoint` property on `options` _will be set to `true`_.
License
-------
(The MIT License) (The MIT License)
Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net> Copyright (c) 2013 Nathan Rajlich &lt;nathan@tootallnate.net&gt;
Permission is hereby granted, free of charge, to any person obtaining Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the a copy of this software and associated documentation files (the
@ -129,8 +246,14 @@ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
[http-proxy-agent]: https://github.com/TooTallNate/node-http-proxy-agent
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
[pac-proxy-agent]: https://github.com/TooTallNate/node-pac-proxy-agent
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
[http.Agent]: https://nodejs.org/api/http.html#http_class_http_agent
========================================= =========================================
END OF agent-base@7.1.3 AND INFORMATION END OF agent-base@6.0.2 AND INFORMATION
%% balanced-match@1.0.2 NOTICES AND INFORMATION BEGIN HERE %% balanced-match@1.0.2 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
@ -542,11 +665,124 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
========================================= =========================================
END OF graceful-fs@4.2.10 AND INFORMATION END OF graceful-fs@4.2.10 AND INFORMATION
%% https-proxy-agent@7.0.6 NOTICES AND INFORMATION BEGIN HERE %% https-proxy-agent@5.0.1 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
https-proxy-agent
================
### An HTTP(s) proxy `http.Agent` implementation for HTTPS
[![Build Status](https://github.com/TooTallNate/node-https-proxy-agent/workflows/Node%20CI/badge.svg)](https://github.com/TooTallNate/node-https-proxy-agent/actions?workflow=Node+CI)
This module provides an `http.Agent` implementation that connects to a specified
HTTP or HTTPS proxy server, and can be used with the built-in `https` module.
Specifically, this `Agent` implementation connects to an intermediary "proxy"
server and issues the [CONNECT HTTP method][CONNECT], which tells the proxy to
open a direct TCP connection to the destination server.
Since this agent implements the CONNECT HTTP method, it also works with other
protocols that use this method when connecting over proxies (i.e. WebSockets).
See the "Examples" section below for more.
Installation
------------
Install with `npm`:
``` bash
$ npm install https-proxy-agent
```
Examples
--------
#### `https` module example
``` js
var url = require('url');
var https = require('https');
var HttpsProxyAgent = require('https-proxy-agent');
// HTTP/HTTPS proxy to connect to
var proxy = process.env.http_proxy || 'http://168.63.76.32:3128';
console.log('using proxy server %j', proxy);
// HTTPS endpoint for the proxy to connect to
var endpoint = process.argv[2] || 'https://graph.facebook.com/tootallnate';
console.log('attempting to GET %j', endpoint);
var options = url.parse(endpoint);
// create an instance of the `HttpsProxyAgent` class with the proxy server information
var agent = new HttpsProxyAgent(proxy);
options.agent = agent;
https.get(options, function (res) {
console.log('"response" event!', res.headers);
res.pipe(process.stdout);
});
```
#### `ws` WebSocket connection example
``` js
var url = require('url');
var WebSocket = require('ws');
var HttpsProxyAgent = require('https-proxy-agent');
// HTTP/HTTPS proxy to connect to
var proxy = process.env.http_proxy || 'http://168.63.76.32:3128';
console.log('using proxy server %j', proxy);
// WebSocket endpoint for the proxy to connect to
var endpoint = process.argv[2] || 'ws://echo.websocket.org';
var parsed = url.parse(endpoint);
console.log('attempting to connect to WebSocket %j', endpoint);
// create an instance of the `HttpsProxyAgent` class with the proxy server information
var options = url.parse(proxy);
var agent = new HttpsProxyAgent(options);
// finally, initiate the WebSocket connection
var socket = new WebSocket(endpoint, { agent: agent });
socket.on('open', function () {
console.log('"open" event!');
socket.send('hello world');
});
socket.on('message', function (data, flags) {
console.log('"message" event! %j %j', data, flags);
socket.close();
});
```
API
---
### new HttpsProxyAgent(Object options)
The `HttpsProxyAgent` class implements an `http.Agent` subclass that connects
to the specified "HTTP(s) proxy server" in order to proxy HTTPS and/or WebSocket
requests. This is achieved by using the [HTTP `CONNECT` method][CONNECT].
The `options` argument may either be a string URI of the proxy server to use, or an
"options" object with more specific properties:
* `host` - String - Proxy host to connect to (may use `hostname` as well). Required.
* `port` - Number - Proxy port to connect to. Required.
* `protocol` - String - If `https:`, then use TLS to connect to the proxy.
* `headers` - Object - Additional HTTP headers to be sent on the HTTP CONNECT method.
* Any other options given are passed to the `net.connect()`/`tls.connect()` functions.
License
-------
(The MIT License) (The MIT License)
Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net> Copyright (c) 2013 Nathan Rajlich &lt;nathan@tootallnate.net&gt;
Permission is hereby granted, free of charge, to any person obtaining Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the a copy of this software and associated documentation files (the
@ -566,8 +802,10 @@ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
[CONNECT]: http://en.wikipedia.org/wiki/HTTP_tunnel#HTTP_CONNECT_Tunneling
========================================= =========================================
END OF https-proxy-agent@7.0.6 AND INFORMATION END OF https-proxy-agent@5.0.1 AND INFORMATION
%% ip-address@9.0.5 NOTICES AND INFORMATION BEGIN HERE %% ip-address@9.0.5 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
@ -1005,11 +1243,141 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
========================================= =========================================
END OF smart-buffer@4.2.0 AND INFORMATION END OF smart-buffer@4.2.0 AND INFORMATION
%% socks-proxy-agent@8.0.5 NOTICES AND INFORMATION BEGIN HERE %% socks-proxy-agent@6.1.1 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
socks-proxy-agent
================
### A SOCKS proxy `http.Agent` implementation for HTTP and HTTPS
[![Build Status](https://github.com/TooTallNate/node-socks-proxy-agent/workflows/Node%20CI/badge.svg)](https://github.com/TooTallNate/node-socks-proxy-agent/actions?workflow=Node+CI)
This module provides an `http.Agent` implementation that connects to a
specified SOCKS proxy server, and can be used with the built-in `http`
and `https` modules.
It can also be used in conjunction with the `ws` module to establish a WebSocket
connection over a SOCKS proxy. See the "Examples" section below.
Installation
------------
Install with `npm`:
``` bash
$ npm install socks-proxy-agent
```
Examples
--------
#### TypeScript example
```ts
import https from 'https';
import { SocksProxyAgent } from 'socks-proxy-agent';
const info = {
host: 'br41.nordvpn.com',
userId: 'your-name@gmail.com',
password: 'abcdef12345124'
};
const agent = new SocksProxyAgent(info);
https.get('https://jsonip.org', { agent }, (res) => {
console.log(res.headers);
res.pipe(process.stdout);
});
```
#### `http` module example
```js
var url = require('url');
var http = require('http');
var SocksProxyAgent = require('socks-proxy-agent');
// SOCKS proxy to connect to
var proxy = process.env.socks_proxy || 'socks://127.0.0.1:1080';
console.log('using proxy server %j', proxy);
// HTTP endpoint for the proxy to connect to
var endpoint = process.argv[2] || 'http://nodejs.org/api/';
console.log('attempting to GET %j', endpoint);
var opts = url.parse(endpoint);
// create an instance of the `SocksProxyAgent` class with the proxy server information
var agent = new SocksProxyAgent(proxy);
opts.agent = agent;
http.get(opts, function (res) {
console.log('"response" event!', res.headers);
res.pipe(process.stdout);
});
```
#### `https` module example
```js
var url = require('url');
var https = require('https');
var SocksProxyAgent = require('socks-proxy-agent');
// SOCKS proxy to connect to
var proxy = process.env.socks_proxy || 'socks://127.0.0.1:1080';
console.log('using proxy server %j', proxy);
// HTTP endpoint for the proxy to connect to
var endpoint = process.argv[2] || 'https://encrypted.google.com/';
console.log('attempting to GET %j', endpoint);
var opts = url.parse(endpoint);
// create an instance of the `SocksProxyAgent` class with the proxy server information
var agent = new SocksProxyAgent(proxy);
opts.agent = agent;
https.get(opts, function (res) {
console.log('"response" event!', res.headers);
res.pipe(process.stdout);
});
```
#### `ws` WebSocket connection example
``` js
var WebSocket = require('ws');
var SocksProxyAgent = require('socks-proxy-agent');
// SOCKS proxy to connect to
var proxy = process.env.socks_proxy || 'socks://127.0.0.1:1080';
console.log('using proxy server %j', proxy);
// WebSocket endpoint for the proxy to connect to
var endpoint = process.argv[2] || 'ws://echo.websocket.org';
console.log('attempting to connect to WebSocket %j', endpoint);
// create an instance of the `SocksProxyAgent` class with the proxy server information
var agent = new SocksProxyAgent(proxy);
// initiate the WebSocket connection
var socket = new WebSocket(endpoint, { agent: agent });
socket.on('open', function () {
console.log('"open" event!');
socket.send('hello world');
});
socket.on('message', function (data, flags) {
console.log('"message" event! %j %j', data, flags);
socket.close();
});
```
License
-------
(The MIT License) (The MIT License)
Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net> Copyright (c) 2013 Nathan Rajlich &lt;nathan@tootallnate.net&gt;
Permission is hereby granted, free of charge, to any person obtaining Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the a copy of this software and associated documentation files (the
@ -1030,7 +1398,7 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
========================================= =========================================
END OF socks-proxy-agent@8.0.5 AND INFORMATION END OF socks-proxy-agent@6.1.1 AND INFORMATION
%% socks@2.8.3 NOTICES AND INFORMATION BEGIN HERE %% socks@2.8.3 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================

View file

@ -9,9 +9,9 @@
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1297", "revision": "1298",
"installByDefault": false, "installByDefault": false,
"browserVersion": "134.0.6974.0" "browserVersion": "134.0.6984.0"
}, },
{ {
"name": "firefox", "name": "firefox",

View file

@ -1,7 +1,7 @@
{ {
"name": "utils-bundle", "name": "utils-bundle",
"version": "0.0.1", "version": "0.0.1",
"lockfileVersion": 2, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
@ -14,7 +14,7 @@
"diff": "^7.0.0", "diff": "^7.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"graceful-fs": "4.2.10", "graceful-fs": "4.2.10",
"https-proxy-agent": "7.0.6", "https-proxy-agent": "5.0.1",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"mime": "^3.0.0", "mime": "^3.0.0",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
@ -24,7 +24,7 @@
"proxy-from-env": "1.1.0", "proxy-from-env": "1.1.0",
"retry": "0.12.0", "retry": "0.12.0",
"signal-exit": "3.0.7", "signal-exit": "3.0.7",
"socks-proxy-agent": "8.0.5", "socks-proxy-agent": "6.1.1",
"stack-utils": "2.0.5", "stack-utils": "2.0.5",
"ws": "8.17.1", "ws": "8.17.1",
"yaml": "^2.6.0" "yaml": "^2.6.0"
@ -140,12 +140,14 @@
} }
}, },
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.3", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT", "dependencies": {
"debug": "4"
},
"engines": { "engines": {
"node": ">= 14" "node": ">= 6.0.0"
} }
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
@ -241,16 +243,15 @@
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
}, },
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.6", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "6",
"debug": "4" "debug": "4"
}, },
"engines": { "engines": {
"node": ">= 14" "node": ">= 6"
} }
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
@ -400,17 +401,16 @@
} }
}, },
"node_modules/socks-proxy-agent": { "node_modules/socks-proxy-agent": {
"version": "8.0.5", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.1.1.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "integrity": "sha512-t8J0kG3csjA4g6FTbsMOWws+7R7vuRC8aQ/wy3/1OWmsgwA68zs/+cExQ0koSitUDXqhufF/YJr9wtNMZHw5Ew==",
"license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^6.0.2",
"debug": "^4.3.4", "debug": "^4.3.1",
"socks": "^2.8.3" "socks": "^2.6.1"
}, },
"engines": { "engines": {
"node": ">= 14" "node": ">= 10"
} }
}, },
"node_modules/sprintf-js": { "node_modules/sprintf-js": {
@ -460,312 +460,5 @@
"node": ">= 14" "node": ">= 14"
} }
} }
},
"dependencies": {
"@types/debug": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
"integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==",
"dev": true,
"requires": {
"@types/ms": "*"
}
},
"@types/diff": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-6.0.0.tgz",
"integrity": "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==",
"dev": true
},
"@types/mime": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz",
"integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==",
"dev": true
},
"@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
"integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==",
"dev": true
},
"@types/ms": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==",
"dev": true
},
"@types/node": {
"version": "17.0.25",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.25.tgz",
"integrity": "sha512-wANk6fBrUwdpY4isjWrKTufkrXdu1D2YHCot2fD/DfWxF5sMrVSA+KN7ydckvaTCh0HiqX9IVl0L5/ZoXg5M7w==",
"dev": true
},
"@types/pngjs": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.1.tgz",
"integrity": "sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/progress": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/progress/-/progress-2.0.5.tgz",
"integrity": "sha512-ZYYVc/kSMkhH9W/4dNK/sLNra3cnkfT2nJyOAIDY+C2u6w72wa0s1aXAezVtbTsnN8HID1uhXCrLwDE2ZXpplg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-kd4LMvcnpYkspDcp7rmXKedn8iJSCoa331zRRamUp5oanKt/CefbEGPQP7G89enz7sKD4bvsr8mHSsC8j5WOvA==",
"dev": true,
"requires": {
"@types/retry": "*"
}
},
"@types/proxy-from-env": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/proxy-from-env/-/proxy-from-env-1.0.1.tgz",
"integrity": "sha512-luG++TFHyS61eKcfkR1CVV6a1GMNXDjtqEQIIfaSHax75xp0HU3SlezjOi1yqubJwrG8e9DeW59n6wTblIDwFg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/retry": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
"dev": true
},
"@types/stack-utils": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
"@types/ws": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz",
"integrity": "sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="
},
"balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="
},
"commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"requires": {
"ms": "2.1.2"
}
},
"define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="
},
"diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="
},
"dotenv": {
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg=="
},
"escape-string-regexp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="
},
"graceful-fs": {
"version": "4.2.10",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
},
"https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"requires": {
"agent-base": "^7.1.2",
"debug": "4"
}
},
"ip-address": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
"requires": {
"jsbn": "1.1.0",
"sprintf-js": "^1.1.3"
}
},
"is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
},
"is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"requires": {
"is-docker": "^2.0.0"
}
},
"jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="
},
"jsbn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="
},
"mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": {
"brace-expansion": "^1.1.7"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"open": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz",
"integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==",
"requires": {
"define-lazy-prop": "^2.0.0",
"is-docker": "^2.1.1",
"is-wsl": "^2.2.0"
}
},
"pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="
},
"progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="
},
"signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
},
"smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="
},
"socks": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
"integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
"requires": {
"ip-address": "^9.0.5",
"smart-buffer": "^4.2.0"
}
},
"socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"requires": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
}
},
"sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
},
"stack-utils": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz",
"integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==",
"requires": {
"escape-string-regexp": "^2.0.0"
}
},
"ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"requires": {}
},
"yaml": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz",
"integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="
}
} }
} }

View file

@ -15,7 +15,7 @@
"diff": "^7.0.0", "diff": "^7.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"graceful-fs": "4.2.10", "graceful-fs": "4.2.10",
"https-proxy-agent": "7.0.6", "https-proxy-agent": "5.0.1",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"mime": "^3.0.0", "mime": "^3.0.0",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
@ -25,7 +25,7 @@
"proxy-from-env": "1.1.0", "proxy-from-env": "1.1.0",
"retry": "0.12.0", "retry": "0.12.0",
"signal-exit": "3.0.7", "signal-exit": "3.0.7",
"socks-proxy-agent": "8.0.5", "socks-proxy-agent": "6.1.1",
"stack-utils": "2.0.5", "stack-utils": "2.0.5",
"ws": "8.17.1", "ws": "8.17.1",
"yaml": "^2.6.0" "yaml": "^2.6.0"

View file

@ -20,6 +20,7 @@ import http from 'http';
import https from 'https'; import https from 'https';
import type { Readable, TransformCallback } from 'stream'; import type { Readable, TransformCallback } from 'stream';
import { pipeline, Transform } from 'stream'; import { pipeline, Transform } from 'stream';
import url from 'url';
import zlib from 'zlib'; import zlib from 'zlib';
import type { HTTPCredentials } from '../../types/types'; import type { HTTPCredentials } from '../../types/types';
import { TimeoutSettings } from '../common/timeoutSettings'; import { TimeoutSettings } from '../common/timeoutSettings';
@ -499,12 +500,12 @@ export abstract class APIRequestContext extends SdkObject {
// happy eyeballs don't emit lookup and connect events, so we use our custom ones // happy eyeballs don't emit lookup and connect events, so we use our custom ones
const happyEyeBallsTimings = timingForSocket(socket); const happyEyeBallsTimings = timingForSocket(socket);
dnsLookupAt = happyEyeBallsTimings.dnsLookupAt; dnsLookupAt = happyEyeBallsTimings.dnsLookupAt;
tcpConnectionAt ??= happyEyeBallsTimings.tcpConnectionAt; tcpConnectionAt = happyEyeBallsTimings.tcpConnectionAt;
// non-happy-eyeballs sockets // non-happy-eyeballs sockets
listeners.push( listeners.push(
eventsHelper.addEventListener(socket, 'lookup', () => { dnsLookupAt = monotonicTime(); }), eventsHelper.addEventListener(socket, 'lookup', () => { dnsLookupAt = monotonicTime(); }),
eventsHelper.addEventListener(socket, 'connect', () => { tcpConnectionAt ??= monotonicTime(); }), eventsHelper.addEventListener(socket, 'connect', () => { tcpConnectionAt = monotonicTime(); }),
eventsHelper.addEventListener(socket, 'secureConnect', () => { eventsHelper.addEventListener(socket, 'secureConnect', () => {
tlsHandshakeAt = monotonicTime(); tlsHandshakeAt = monotonicTime();
@ -521,21 +522,11 @@ export abstract class APIRequestContext extends SdkObject {
}), }),
); );
// when using socks proxy, having the socket means the connection got established
if (agent instanceof SocksProxyAgent)
tcpConnectionAt ??= monotonicTime();
serverIPAddress = socket.remoteAddress; serverIPAddress = socket.remoteAddress;
serverPort = socket.remotePort; serverPort = socket.remotePort;
}); });
request.on('finish', () => { requestFinishAt = monotonicTime(); }); request.on('finish', () => { requestFinishAt = monotonicTime(); });
// http proxy
request.on('proxyConnect', () => {
tcpConnectionAt ??= monotonicTime();
});
progress.log(`${options.method} ${url.toString()}`); progress.log(`${options.method} ${url.toString()}`);
if (options.headers) { if (options.headers) {
for (const [name, value] of Object.entries(options.headers)) for (const [name, value] of Object.entries(options.headers))
@ -702,16 +693,17 @@ export class GlobalAPIRequestContext extends APIRequestContext {
} }
export function createProxyAgent(proxy: types.ProxySettings) { export function createProxyAgent(proxy: types.ProxySettings) {
const proxyURL = new URL(proxy.server); const proxyOpts = url.parse(proxy.server);
if (proxyURL.protocol?.startsWith('socks')) if (proxyOpts.protocol?.startsWith('socks')) {
return new SocksProxyAgent(proxyURL); return new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
}
if (proxy.username) if (proxy.username)
proxyURL.username = proxy.username; proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
if (proxy.password) // TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
proxyURL.password = proxy.password; return new HttpsProxyAgent(proxyOpts);
// TODO: We should use HttpProxyAgent conditional on proxyURL.protocol instead of always using CONNECT method.
return new HttpsProxyAgent(proxyURL);
} }
function toHeadersArray(rawHeaders: string[]): types.HeadersArray { function toHeadersArray(rawHeaders: string[]): types.HeadersArray {

View file

@ -435,4 +435,6 @@ function toJugglerProxyOptions(proxy: types.ProxySettings) {
// Prefs for quick fixes that didn't make it to the build. // Prefs for quick fixes that didn't make it to the build.
// Should all be moved to `playwright.cfg`. // Should all be moved to `playwright.cfg`.
const kBandaidFirefoxUserPrefs = {}; const kBandaidFirefoxUserPrefs = {
'dom.fetchKeepalive.enabled': false,
};

View file

@ -34,6 +34,7 @@ export class PollingRecorder implements RecorderDelegate {
private _recorder: Recorder; private _recorder: Recorder;
private _embedder: Embedder; private _embedder: Embedder;
private _pollRecorderModeTimer: number | undefined; private _pollRecorderModeTimer: number | undefined;
private _lastStateJSON: string | undefined;
constructor(injectedScript: InjectedScript) { constructor(injectedScript: InjectedScript) {
this._recorder = new Recorder(injectedScript); this._recorder = new Recorder(injectedScript);
@ -42,6 +43,7 @@ export class PollingRecorder implements RecorderDelegate {
injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners()); injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners());
const refreshOverlay = () => { const refreshOverlay = () => {
this._lastStateJSON = undefined;
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
}; };
this._embedder.__pw_refreshOverlay = refreshOverlay; this._embedder.__pw_refreshOverlay = refreshOverlay;
@ -57,13 +59,19 @@ export class PollingRecorder implements RecorderDelegate {
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
return; return;
} }
const win = this._recorder.document.defaultView!;
if (win.top !== win) { const stringifiedState = JSON.stringify(state);
// Only show action point in the main frame, since it is relative to the page's viewport. if (this._lastStateJSON !== stringifiedState) {
// Otherwise we'll see multiple action points at different locations. this._lastStateJSON = stringifiedState;
state.actionPoint = undefined; const win = this._recorder.document.defaultView!;
if (win.top !== win) {
// Only show action point in the main frame, since it is relative to the page's viewport.
// Otherwise we'll see multiple action points at different locations.
state.actionPoint = undefined;
}
this._recorder.setUIState(state, this);
} }
this._recorder.setUIState(state, this);
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
} }

View file

@ -354,27 +354,34 @@ export function getPseudoContent(element: Element, pseudo: '::before' | '::after
if (cache?.has(element)) if (cache?.has(element))
return cache?.get(element) || ''; return cache?.get(element) || '';
const pseudoStyle = getElementComputedStyle(element, pseudo); const pseudoStyle = getElementComputedStyle(element, pseudo);
const content = getPseudoContentImpl(pseudoStyle); const content = getPseudoContentImpl(element, pseudoStyle);
if (cache) if (cache)
cache.set(element, content); cache.set(element, content);
return content; return content;
} }
function getPseudoContentImpl(pseudoStyle: CSSStyleDeclaration | undefined) { function getPseudoContentImpl(element: Element, pseudoStyle: CSSStyleDeclaration | undefined) {
// Note: all browsers ignore display:none and visibility:hidden pseudos. // Note: all browsers ignore display:none and visibility:hidden pseudos.
if (!pseudoStyle || pseudoStyle.display === 'none' || pseudoStyle.visibility === 'hidden') if (!pseudoStyle || pseudoStyle.display === 'none' || pseudoStyle.visibility === 'hidden')
return ''; return '';
const content = pseudoStyle.content; const content = pseudoStyle.content;
let resolvedContent: string | undefined;
if ((content[0] === '\'' && content[content.length - 1] === '\'') || if ((content[0] === '\'' && content[content.length - 1] === '\'') ||
(content[0] === '"' && content[content.length - 1] === '"')) { (content[0] === '"' && content[content.length - 1] === '"')) {
const unquoted = content.substring(1, content.length - 1); resolvedContent = content.substring(1, content.length - 1);
} else if (content.startsWith('attr(') && content.endsWith(')')) {
// Firefox does not resolve attribute accessors in content.
const attrName = content.substring('attr('.length, content.length - 1).trim();
resolvedContent = element.getAttribute(attrName) || '';
}
if (resolvedContent !== undefined) {
// SPEC DIFFERENCE. // SPEC DIFFERENCE.
// Spec says "CSS textual content, without a space", but we account for display // Spec says "CSS textual content, without a space", but we account for display
// to pass "name_file-label-inline-block-styles-manual.html" // to pass "name_file-label-inline-block-styles-manual.html"
const display = pseudoStyle.display || 'inline'; const display = pseudoStyle.display || 'inline';
if (display !== 'inline') if (display !== 'inline')
return ' ' + unquoted + ' '; return ' ' + resolvedContent + ' ';
return unquoted; return resolvedContent;
} }
return ''; return '';
} }

View file

@ -85,7 +85,7 @@ export class RecorderCollection extends EventEmitter {
let generateGoto = false; let generateGoto = false;
if (!lastAction) if (!lastAction)
generateGoto = true; generateGoto = true;
else if (lastAction.action.name !== 'click' && lastAction.action.name !== 'press') else if (lastAction.action.name !== 'click' && lastAction.action.name !== 'press' && lastAction.action.name !== 'fill')
generateGoto = true; generateGoto = true;
else if (timestamp - lastAction.startTime > signalThreshold) else if (timestamp - lastAction.startTime > signalThreshold)
generateGoto = true; generateGoto = true;

View file

@ -98,7 +98,7 @@ class SocksProxyConnection {
async connect() { async connect() {
if (this.socksProxy.proxyAgentFromOptions) if (this.socksProxy.proxyAgentFromOptions)
this.target = await this.socksProxy.proxyAgentFromOptions.connect(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false }); this.target = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false });
else else
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);

View file

@ -50,7 +50,7 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco
const proxyURL = getProxyForUrl(params.url); const proxyURL = getProxyForUrl(params.url);
if (proxyURL) { if (proxyURL) {
const parsedProxyURL = new URL(proxyURL); const parsedProxyURL = url.parse(proxyURL);
if (params.url.startsWith('http:')) { if (params.url.startsWith('http:')) {
options = { options = {
path: parsedUrl.href, path: parsedUrl.href,

View file

@ -46,6 +46,7 @@ export class FullConfigInternal {
readonly plugins: TestRunnerPluginRegistration[]; readonly plugins: TestRunnerPluginRegistration[];
readonly projects: FullProjectInternal[] = []; readonly projects: FullProjectInternal[] = [];
readonly singleTSConfigPath?: string; readonly singleTSConfigPath?: string;
readonly populateGitInfo: boolean;
cliArgs: string[] = []; cliArgs: string[] = [];
cliGrep: string | undefined; cliGrep: string | undefined;
cliGrepInvert: string | undefined; cliGrepInvert: string | undefined;
@ -75,10 +76,15 @@ export class FullConfigInternal {
const privateConfiguration = (userConfig as any)['@playwright/test']; const privateConfiguration = (userConfig as any)['@playwright/test'];
this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p })); this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p }));
this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig); this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig);
this.populateGitInfo = takeFirst(userConfig.populateGitInfo, false);
this.globalSetups = (Array.isArray(userConfig.globalSetup) ? userConfig.globalSetup : [userConfig.globalSetup]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined); this.globalSetups = (Array.isArray(userConfig.globalSetup) ? userConfig.globalSetup : [userConfig.globalSetup]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined);
this.globalTeardowns = (Array.isArray(userConfig.globalTeardown) ? userConfig.globalTeardown : [userConfig.globalTeardown]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined); this.globalTeardowns = (Array.isArray(userConfig.globalTeardown) ? userConfig.globalTeardown : [userConfig.globalTeardown]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined);
// Make sure we reuse same metadata instance between FullConfigInternal instances,
// so that plugins such as gitCommitInfoPlugin can populate metadata once.
userConfig.metadata = userConfig.metadata || {};
this.config = { this.config = {
configFile: resolvedConfigFile, configFile: resolvedConfigFile,
rootDir: pathResolve(configDir, userConfig.testDir) || configDir, rootDir: pathResolve(configDir, userConfig.testDir) || configDir,
@ -90,7 +96,7 @@ export class FullConfigInternal {
grep: takeFirst(userConfig.grep, defaultGrep), grep: takeFirst(userConfig.grep, defaultGrep),
grepInvert: takeFirst(userConfig.grepInvert, null), grepInvert: takeFirst(userConfig.grepInvert, null),
maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0), maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0),
metadata: takeFirst(userConfig.metadata, {}), metadata: userConfig.metadata,
preserveOutput: takeFirst(userConfig.preserveOutput, 'always'), preserveOutput: takeFirst(userConfig.preserveOutput, 'always'),
reporter: takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]), reporter: takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]),
reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 15000 }), reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 15000 }),
@ -164,7 +170,7 @@ export class FullProjectInternal {
readonly fullyParallel: boolean; readonly fullyParallel: boolean;
readonly expect: Project['expect']; readonly expect: Project['expect'];
readonly respectGitIgnore: boolean; readonly respectGitIgnore: boolean;
readonly snapshotPathTemplate: string; readonly snapshotPathTemplate: string | undefined;
readonly ignoreSnapshots: boolean; readonly ignoreSnapshots: boolean;
id = ''; id = '';
deps: FullProjectInternal[] = []; deps: FullProjectInternal[] = [];
@ -173,8 +179,7 @@ export class FullProjectInternal {
constructor(configDir: string, config: Config, fullConfig: FullConfigInternal, projectConfig: Project, configCLIOverrides: ConfigCLIOverrides, packageJsonDir: string) { constructor(configDir: string, config: Config, fullConfig: FullConfigInternal, projectConfig: Project, configCLIOverrides: ConfigCLIOverrides, packageJsonDir: string) {
this.fullConfig = fullConfig; this.fullConfig = fullConfig;
const testDir = takeFirst(pathResolve(configDir, projectConfig.testDir), pathResolve(configDir, config.testDir), fullConfig.configDir); const testDir = takeFirst(pathResolve(configDir, projectConfig.testDir), pathResolve(configDir, config.testDir), fullConfig.configDir);
const defaultSnapshotPathTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}'; this.snapshotPathTemplate = takeFirst(projectConfig.snapshotPathTemplate, config.snapshotPathTemplate);
this.snapshotPathTemplate = takeFirst(projectConfig.snapshotPathTemplate, config.snapshotPathTemplate, defaultSnapshotPathTemplate);
this.project = { this.project = {
grep: takeFirst(projectConfig.grep, config.grep, defaultGrep), grep: takeFirst(projectConfig.grep, config.grep, defaultGrep),

View file

@ -0,0 +1,25 @@
/**
* 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.
*/
export interface GitCommitInfo {
'revision.id'?: string;
'revision.author'?: string;
'revision.email'?: string;
'revision.subject'?: string;
'revision.timestamp'?: number | Date;
'revision.link'?: string;
'ci.link'?: string;
}

View file

@ -49,6 +49,8 @@ export async function toMatchAriaSnapshot(
return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected: '' }; return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected: '' };
const updateSnapshots = testInfo.config.updateSnapshots; const updateSnapshots = testInfo.config.updateSnapshots;
const pathTemplate = testInfo._projectInternal.expect?.toMatchAriaSnapshot?.pathTemplate;
const defaultTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}';
const matcherOptions = { const matcherOptions = {
isNot: this.isNot, isNot: this.isNot,
@ -63,7 +65,7 @@ export async function toMatchAriaSnapshot(
timeout = options.timeout ?? this.timeout; timeout = options.timeout ?? this.timeout;
} else { } else {
if (expectedParam?.name) { if (expectedParam?.name) {
expectedPath = testInfo.snapshotPath(sanitizeFilePathBeforeExtension(expectedParam.name)); expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name)]);
} else { } else {
let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames; let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames;
if (!snapshotNames) { if (!snapshotNames) {
@ -71,7 +73,7 @@ export async function toMatchAriaSnapshot(
(testInfo as any)[snapshotNamesSymbol] = snapshotNames; (testInfo as any)[snapshotNamesSymbol] = snapshotNames;
} }
const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' '); const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' ');
expectedPath = testInfo.snapshotPath(sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml'); expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml']);
} }
expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => ''); expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => '');
timeout = expectedParam?.timeout ?? this.timeout; timeout = expectedParam?.timeout ?? this.timeout;

View file

@ -148,7 +148,8 @@ class SnapshotHelper {
outputBasePath = testInfo._getOutputPath(sanitizedName); outputBasePath = testInfo._getOutputPath(sanitizedName);
this.attachmentBaseName = sanitizedName; this.attachmentBaseName = sanitizedName;
} }
this.expectedPath = testInfo.snapshotPath(...expectedPathSegments); const defaultTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
this.expectedPath = testInfo._resolveSnapshotPath(configOptions.pathTemplate, defaultTemplate, expectedPathSegments);
this.legacyExpectedPath = addSuffixToFilePath(outputBasePath, '-expected'); this.legacyExpectedPath = addSuffixToFilePath(outputBasePath, '-expected');
this.previousPath = addSuffixToFilePath(outputBasePath, '-previous'); this.previousPath = addSuffixToFilePath(outputBasePath, '-previous');
this.actualPath = addSuffixToFilePath(outputBasePath, '-actual'); this.actualPath = addSuffixToFilePath(outputBasePath, '-actual');

View file

@ -17,46 +17,38 @@
import { createGuid, spawnAsync } from 'playwright-core/lib/utils'; import { createGuid, spawnAsync } from 'playwright-core/lib/utils';
import type { TestRunnerPlugin } from './'; import type { TestRunnerPlugin } from './';
import type { FullConfig } from '../../types/testReporter'; import type { FullConfig } from '../../types/testReporter';
import type { FullConfigInternal } from '../common/config';
import type { GitCommitInfo } from '../isomorphic/types';
const GIT_OPERATIONS_TIMEOUT_MS = 1500; const GIT_OPERATIONS_TIMEOUT_MS = 1500;
export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => {
if (fullConfig.populateGitInfo)
fullConfig.plugins.push({ factory: gitCommitInfo });
};
export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => { export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => {
return { return {
name: 'playwright:git-commit-info', name: 'playwright:git-commit-info',
setup: async (config: FullConfig, configDir: string) => { setup: async (config: FullConfig, configDir: string) => {
const info = { const fromEnv = linksFromEnv();
...linksFromEnv(), const fromCLI = await gitStatusFromCLI(options?.directory || configDir);
...options?.info ? options.info : await gitStatusFromCLI(options?.directory || configDir), const info = { ...fromEnv, ...fromCLI };
timestamp: Date.now(), if (info['revision.timestamp'] instanceof Date)
}; info['revision.timestamp'] = info['revision.timestamp'].getTime();
// Normalize dates
const timestamp = info['revision.timestamp'];
if (timestamp instanceof Date)
info['revision.timestamp'] = timestamp.getTime();
config.metadata = config.metadata || {}; config.metadata = config.metadata || {};
Object.assign(config.metadata, info); config.metadata['git.commit.info'] = info;
}, },
}; };
}; };
export interface GitCommitInfoPluginOptions { interface GitCommitInfoPluginOptions {
directory?: string; directory?: string;
info?: Info;
} }
export interface Info { function linksFromEnv(): Pick<GitCommitInfo, 'revision.link' | 'ci.link'> {
'revision.id'?: string;
'revision.author'?: string;
'revision.email'?: string;
'revision.subject'?: string;
'revision.timestamp'?: number | Date;
'revision.link'?: string;
'ci.link'?: string;
}
const linksFromEnv = (): Pick<Info, 'revision.link' | 'ci.link'> => {
const out: { 'revision.link'?: string; 'ci.link'?: string; } = {}; const out: { 'revision.link'?: string; 'ci.link'?: string; } = {};
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables // Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
if (process.env.BUILD_URL) if (process.env.BUILD_URL)
@ -72,9 +64,9 @@ const linksFromEnv = (): Pick<Info, 'revision.link' | 'ci.link'> => {
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID) if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID)
out['ci.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; out['ci.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
return out; return out;
}; }
export const gitStatusFromCLI = async (gitDir: string): Promise<Info | undefined> => { async function gitStatusFromCLI(gitDir: string): Promise<GitCommitInfo | undefined> {
const separator = `:${createGuid().slice(0, 4)}:`; const separator = `:${createGuid().slice(0, 4)}:`;
const { code, stdout } = await spawnAsync( const { code, stdout } = await spawnAsync(
'git', 'git',
@ -95,4 +87,4 @@ export const gitStatusFromCLI = async (gitDir: string): Promise<Info | undefined
'revision.subject': subject, 'revision.subject': subject,
'revision.timestamp': timestamp, 'revision.timestamp': timestamp,
}; };
}; }

View file

@ -43,6 +43,10 @@ export function addSuggestedRebaseline(location: Location, suggestedRebaseline:
suggestedRebaselines.set(location.file, { location, code: suggestedRebaseline }); suggestedRebaselines.set(location.file, { location, code: suggestedRebaseline });
} }
export function clearSuggestedRebaselines() {
suggestedRebaselines.clear();
}
export async function applySuggestedRebaselines(config: FullConfigInternal, reporter: InternalReporter) { export async function applySuggestedRebaselines(config: FullConfigInternal, reporter: InternalReporter) {
if (config.config.updateSnapshots === 'none') if (config.config.updateSnapshots === 'none')
return; return;

View file

@ -17,6 +17,7 @@
import type { FullResult, TestError } from '../../types/testReporter'; import type { FullResult, TestError } from '../../types/testReporter';
import { webServerPluginsForConfig } from '../plugins/webServerPlugin'; import { webServerPluginsForConfig } from '../plugins/webServerPlugin';
import { addGitCommitInfoPlugin } from '../plugins/gitCommitInfoPlugin';
import { collectFilesForProject, filterProjects } from './projectUtils'; import { collectFilesForProject, filterProjects } from './projectUtils';
import { createErrorCollectingReporter, createReporters } from './reporters'; import { createErrorCollectingReporter, createReporters } from './reporters';
import { TestRun, createApplyRebaselinesTask, createClearCacheTask, createGlobalSetupTasks, createLoadTask, createPluginSetupTasks, createReportBeginTask, createRunTestsTasks, createStartDevServerTask, runTasks } from './tasks'; import { TestRun, createApplyRebaselinesTask, createClearCacheTask, createGlobalSetupTasks, createLoadTask, createPluginSetupTasks, createReportBeginTask, createRunTestsTasks, createStartDevServerTask, runTasks } from './tasks';
@ -70,6 +71,8 @@ export class Runner {
const config = this._config; const config = this._config;
const listOnly = config.cliListOnly; const listOnly = config.cliListOnly;
addGitCommitInfoPlugin(config);
// Legacy webServer support. // Legacy webServer support.
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));

View file

@ -34,7 +34,7 @@ import { detectChangedTestFiles } from './vcs';
import type { InternalReporter } from '../reporters/internalReporter'; import type { InternalReporter } from '../reporters/internalReporter';
import { cacheDir } from '../transform/compilationCache'; import { cacheDir } from '../transform/compilationCache';
import type { FullResult } from '../../types/testReporter'; import type { FullResult } from '../../types/testReporter';
import { applySuggestedRebaselines } from './rebase'; import { applySuggestedRebaselines, clearSuggestedRebaselines } from './rebase';
const readDirAsync = promisify(fs.readdir); const readDirAsync = promisify(fs.readdir);
@ -284,6 +284,9 @@ export function createLoadTask(mode: 'out-of-process' | 'in-process', options: {
export function createApplyRebaselinesTask(): Task<TestRun> { export function createApplyRebaselinesTask(): Task<TestRun> {
return { return {
title: 'apply rebaselines', title: 'apply rebaselines',
setup: async () => {
clearSuggestedRebaselines();
},
teardown: async ({ config, reporter }) => { teardown: async ({ config, reporter }) => {
await applySuggestedRebaselines(config, reporter); await applySuggestedRebaselines(config, reporter);
}, },

View file

@ -39,6 +39,7 @@ import { baseFullConfig } from '../isomorphic/teleReceiver';
import { InternalReporter } from '../reporters/internalReporter'; import { InternalReporter } from '../reporters/internalReporter';
import type { ReporterV2 } from '../reporters/reporterV2'; import type { ReporterV2 } from '../reporters/reporterV2';
import { internalScreen } from '../reporters/base'; import { internalScreen } from '../reporters/base';
import { addGitCommitInfoPlugin } from '../plugins/gitCommitInfoPlugin';
const originalStdoutWrite = process.stdout.write; const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write; const originalStderrWrite = process.stderr.write;
@ -406,6 +407,7 @@ export class TestServerDispatcher implements TestServerInterface {
// Preserve plugin instances between setup and build. // Preserve plugin instances between setup and build.
if (!this._plugins) { if (!this._plugins) {
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
addGitCommitInfoPlugin(config);
this._plugins = config.plugins || []; this._plugins = config.plugins || [];
} else { } else {
config.plugins.splice(0, config.plugins.length, ...this._plugins); config.plugins.splice(0, config.plugins.length, ...this._plugins);

View file

@ -454,14 +454,15 @@ export class TestInfoImpl implements TestInfo {
return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)); return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec));
} }
snapshotPath(...pathSegments: string[]) { _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[]) {
const subPath = path.join(...pathSegments); const subPath = path.join(...pathSegments);
const parsedSubPath = path.parse(subPath); const parsedSubPath = path.parse(subPath);
const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile); const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile);
const parsedRelativeTestFilePath = path.parse(relativeTestFilePath); const parsedRelativeTestFilePath = path.parse(relativeTestFilePath);
const projectNamePathSegment = sanitizeForFilePath(this.project.name); const projectNamePathSegment = sanitizeForFilePath(this.project.name);
const snapshotPath = (this._projectInternal.snapshotPathTemplate || '') const actualTemplate = (template || this._projectInternal.snapshotPathTemplate || defaultTemplate);
const snapshotPath = actualTemplate
.replace(/\{(.)?testDir\}/g, '$1' + this.project.testDir) .replace(/\{(.)?testDir\}/g, '$1' + this.project.testDir)
.replace(/\{(.)?snapshotDir\}/g, '$1' + this.project.snapshotDir) .replace(/\{(.)?snapshotDir\}/g, '$1' + this.project.snapshotDir)
.replace(/\{(.)?snapshotSuffix\}/g, this.snapshotSuffix ? '$1' + this.snapshotSuffix : '') .replace(/\{(.)?snapshotSuffix\}/g, this.snapshotSuffix ? '$1' + this.snapshotSuffix : '')
@ -477,6 +478,11 @@ export class TestInfoImpl implements TestInfo {
return path.normalize(path.resolve(this._configInternal.configDir, snapshotPath)); return path.normalize(path.resolve(this._configInternal.configDir, snapshotPath));
} }
snapshotPath(...pathSegments: string[]) {
const legacyTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';
return this._resolveSnapshotPath(undefined, legacyTemplate, pathSegments);
}
skip(...args: [arg?: any, description?: string]) { skip(...args: [arg?: any, description?: string]) {
this._modifier('skip', args); this._modifier('skip', args);
} }

View file

@ -214,6 +214,27 @@ interface TestProject<TestArgs = {}, WorkerArgs = {}> {
* [page.screenshot([options])](https://playwright.dev/docs/api/class-page#page-screenshot). * [page.screenshot([options])](https://playwright.dev/docs/api/class-page#page-screenshot).
*/ */
stylePath?: string|Array<string>; stylePath?: string|Array<string>;
/**
* A template controlling location of the screenshots. See
* [testProject.snapshotPathTemplate](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-path-template)
* for details.
*/
pathTemplate?: string;
};
/**
* Configuration for the
* [expect(locator).toMatchAriaSnapshot([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2)
* method.
*/
toMatchAriaSnapshot?: {
/**
* A template controlling location of the aria snapshots. See
* [testProject.snapshotPathTemplate](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-path-template)
* for details.
*/
pathTemplate?: string;
}; };
/** /**
@ -404,10 +425,14 @@ interface TestProject<TestArgs = {}, WorkerArgs = {}> {
/** /**
* This option configures a template controlling location of snapshots generated by * This option configures a template controlling location of snapshots generated by
* [expect(page).toHaveScreenshot(name[, options])](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1) * [expect(page).toHaveScreenshot(name[, options])](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1),
* [expect(locator).toMatchAriaSnapshot([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2)
* and * and
* [expect(value).toMatchSnapshot(name[, options])](https://playwright.dev/docs/api/class-snapshotassertions#snapshot-assertions-to-match-snapshot-1). * [expect(value).toMatchSnapshot(name[, options])](https://playwright.dev/docs/api/class-snapshotassertions#snapshot-assertions-to-match-snapshot-1).
* *
* You can configure templates for each assertion separately in
* [testConfig.expect](https://playwright.dev/docs/api/class-testconfig#test-config-expect).
*
* **Usage** * **Usage**
* *
* ```js * ```js
@ -416,7 +441,19 @@ interface TestProject<TestArgs = {}, WorkerArgs = {}> {
* *
* export default defineConfig({ * export default defineConfig({
* testDir: './tests', * testDir: './tests',
*
* // Single template for all assertions
* snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', * snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',
*
* // Assertion-specific templates
* expect: {
* toHaveScreenshot: {
* pathTemplate: '{testDir}/__screenshots__{/projectName}/{testFilePath}/{arg}{ext}',
* },
* toMatchAriaSnapshot: {
* pathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}',
* },
* },
* }); * });
* ``` * ```
* *
@ -447,27 +484,27 @@ interface TestProject<TestArgs = {}, WorkerArgs = {}> {
* ``` * ```
* *
* The list of supported tokens: * The list of supported tokens:
* - `{arg}` - Relative snapshot path **without extension**. These come from the arguments passed to the * - `{arg}` - Relative snapshot path **without extension**. This comes from the arguments passed to
* `toHaveScreenshot()` and `toMatchSnapshot()` calls; if called without arguments, this will be an auto-generated * `toHaveScreenshot()`, `toMatchAriaSnapshot()` or `toMatchSnapshot()`; if called without arguments, this will be
* snapshot name. * an auto-generated snapshot name.
* - Value: `foo/bar/baz` * - Value: `foo/bar/baz`
* - `{ext}` - snapshot extension (with dots) * - `{ext}` - Snapshot extension (with the leading dot).
* - Value: `.png` * - Value: `.png`
* - `{platform}` - The value of `process.platform`. * - `{platform}` - The value of `process.platform`.
* - `{projectName}` - Project's file-system-sanitized name, if any. * - `{projectName}` - Project's file-system-sanitized name, if any.
* - Value: `''` (empty string). * - Value: `''` (empty string).
* - `{snapshotDir}` - Project's * - `{snapshotDir}` - Project's
* [testConfig.snapshotDir](https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-dir). * [testProject.snapshotDir](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-dir).
* - Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`) * - Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`)
* - `{testDir}` - Project's * - `{testDir}` - Project's
* [testConfig.testDir](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir). * [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir).
* - Value: `/home/playwright/tests` (absolute path is since `testDir` is resolved relative to directory with * - Value: `/home/playwright/tests` (absolute path since `testDir` is resolved relative to directory with
* config) * config)
* - `{testFileDir}` - Directories in relative path from `testDir` to **test file**. * - `{testFileDir}` - Directories in relative path from `testDir` to **test file**.
* - Value: `page` * - Value: `page`
* - `{testFileName}` - Test file name with extension. * - `{testFileName}` - Test file name with extension.
* - Value: `page-click.spec.ts` * - Value: `page-click.spec.ts`
* - `{testFilePath}` - Relative path from `testDir` to **test file** * - `{testFilePath}` - Relative path from `testDir` to **test file**.
* - Value: `page/page-click.spec.ts` * - Value: `page/page-click.spec.ts`
* - `{testName}` - File-system-sanitized test title, including parent describes but excluding file name. * - `{testName}` - File-system-sanitized test title, including parent describes but excluding file name.
* - Value: `suite-test-should-work` * - Value: `suite-test-should-work`
@ -991,6 +1028,27 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
* [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`. * [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`.
*/ */
threshold?: number; threshold?: number;
/**
* A template controlling location of the screenshots. See
* [testConfig.snapshotPathTemplate](https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-path-template)
* for details.
*/
pathTemplate?: string;
};
/**
* Configuration for the
* [expect(locator).toMatchAriaSnapshot([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2)
* method.
*/
toMatchAriaSnapshot?: {
/**
* A template controlling location of the aria snapshots. See
* [testConfig.snapshotPathTemplate](https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-path-template)
* for details.
*/
pathTemplate?: string;
}; };
/** /**
@ -1220,7 +1278,12 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
maxFailures?: number; maxFailures?: number;
/** /**
* Metadata that will be put directly to the test report serialized as JSON. * Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as
* key-value pairs, and JSON report will include metadata serialized as json.
*
* See also
* [testConfig.populateGitInfo](https://playwright.dev/docs/api/class-testconfig#test-config-populate-git-info) that
* populates metadata.
* *
* **Usage** * **Usage**
* *
@ -1229,7 +1292,7 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
* import { defineConfig } from '@playwright/test'; * import { defineConfig } from '@playwright/test';
* *
* export default defineConfig({ * export default defineConfig({
* metadata: 'acceptance tests', * metadata: { title: 'acceptance tests' },
* }); * });
* ``` * ```
* *
@ -1293,6 +1356,27 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
*/ */
outputDir?: string; outputDir?: string;
/**
* Whether to populate `'git.commit.info'` field of the
* [testConfig.metadata](https://playwright.dev/docs/api/class-testconfig#test-config-metadata) with Git commit info
* and CI/CD information.
*
* This information will appear in the HTML and JSON reports and is available in the Reporter API.
*
* **Usage**
*
* ```js
* // playwright.config.ts
* import { defineConfig } from '@playwright/test';
*
* export default defineConfig({
* populateGitInfo: !!process.env.CI,
* });
* ```
*
*/
populateGitInfo?: boolean;
/** /**
* Whether to preserve test output in the * Whether to preserve test output in the
* [testConfig.outputDir](https://playwright.dev/docs/api/class-testconfig#test-config-output-dir). Defaults to * [testConfig.outputDir](https://playwright.dev/docs/api/class-testconfig#test-config-output-dir). Defaults to
@ -1468,10 +1552,14 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
/** /**
* This option configures a template controlling location of snapshots generated by * This option configures a template controlling location of snapshots generated by
* [expect(page).toHaveScreenshot(name[, options])](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1) * [expect(page).toHaveScreenshot(name[, options])](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1),
* [expect(locator).toMatchAriaSnapshot([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2)
* and * and
* [expect(value).toMatchSnapshot(name[, options])](https://playwright.dev/docs/api/class-snapshotassertions#snapshot-assertions-to-match-snapshot-1). * [expect(value).toMatchSnapshot(name[, options])](https://playwright.dev/docs/api/class-snapshotassertions#snapshot-assertions-to-match-snapshot-1).
* *
* You can configure templates for each assertion separately in
* [testConfig.expect](https://playwright.dev/docs/api/class-testconfig#test-config-expect).
*
* **Usage** * **Usage**
* *
* ```js * ```js
@ -1480,7 +1568,19 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
* *
* export default defineConfig({ * export default defineConfig({
* testDir: './tests', * testDir: './tests',
*
* // Single template for all assertions
* snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', * snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',
*
* // Assertion-specific templates
* expect: {
* toHaveScreenshot: {
* pathTemplate: '{testDir}/__screenshots__{/projectName}/{testFilePath}/{arg}{ext}',
* },
* toMatchAriaSnapshot: {
* pathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}',
* },
* },
* }); * });
* ``` * ```
* *
@ -1511,27 +1611,27 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
* ``` * ```
* *
* The list of supported tokens: * The list of supported tokens:
* - `{arg}` - Relative snapshot path **without extension**. These come from the arguments passed to the * - `{arg}` - Relative snapshot path **without extension**. This comes from the arguments passed to
* `toHaveScreenshot()` and `toMatchSnapshot()` calls; if called without arguments, this will be an auto-generated * `toHaveScreenshot()`, `toMatchAriaSnapshot()` or `toMatchSnapshot()`; if called without arguments, this will be
* snapshot name. * an auto-generated snapshot name.
* - Value: `foo/bar/baz` * - Value: `foo/bar/baz`
* - `{ext}` - snapshot extension (with dots) * - `{ext}` - Snapshot extension (with the leading dot).
* - Value: `.png` * - Value: `.png`
* - `{platform}` - The value of `process.platform`. * - `{platform}` - The value of `process.platform`.
* - `{projectName}` - Project's file-system-sanitized name, if any. * - `{projectName}` - Project's file-system-sanitized name, if any.
* - Value: `''` (empty string). * - Value: `''` (empty string).
* - `{snapshotDir}` - Project's * - `{snapshotDir}` - Project's
* [testConfig.snapshotDir](https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-dir). * [testProject.snapshotDir](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-dir).
* - Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`) * - Value: `/home/playwright/tests` (since `snapshotDir` is not provided in config, it defaults to `testDir`)
* - `{testDir}` - Project's * - `{testDir}` - Project's
* [testConfig.testDir](https://playwright.dev/docs/api/class-testconfig#test-config-test-dir). * [testProject.testDir](https://playwright.dev/docs/api/class-testproject#test-project-test-dir).
* - Value: `/home/playwright/tests` (absolute path is since `testDir` is resolved relative to directory with * - Value: `/home/playwright/tests` (absolute path since `testDir` is resolved relative to directory with
* config) * config)
* - `{testFileDir}` - Directories in relative path from `testDir` to **test file**. * - `{testFileDir}` - Directories in relative path from `testDir` to **test file**.
* - Value: `page` * - Value: `page`
* - `{testFileName}` - Test file name with extension. * - `{testFileName}` - Test file name with extension.
* - Value: `page-click.spec.ts` * - Value: `page-click.spec.ts`
* - `{testFilePath}` - Relative path from `testDir` to **test file** * - `{testFilePath}` - Relative path from `testDir` to **test file**.
* - Value: `page/page-click.spec.ts` * - Value: `page/page-click.spec.ts`
* - `{testName}` - File-system-sanitized test title, including parent describes but excluding file name. * - `{testName}` - File-system-sanitized test title, including parent describes but excluding file name.
* - Value: `suite-test-should-work` * - Value: `suite-test-should-work`
@ -8685,20 +8785,23 @@ interface LocatorAssertions {
/** /**
* Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots). * Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots).
* *
* Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate`
* and/or `snapshotPathTemplate` properties in the configuration file.
*
* **Usage** * **Usage**
* *
* ```js * ```js
* await expect(page.locator('body')).toMatchAriaSnapshot(); * await expect(page.locator('body')).toMatchAriaSnapshot();
* await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot.yml' });
* *
* await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' });
* ``` * ```
* *
* @param options * @param options
*/ */
toMatchAriaSnapshot(options?: { toMatchAriaSnapshot(options?: {
/** /**
* Name of the snapshot to store in the snapshot (screenshot) folder corresponding to this test. Generates sequential * Name of the snapshot to store in the snapshot folder corresponding to this test. Generates sequential names if not
* names if not specified. * specified.
*/ */
name?: string; name?: string;

View file

@ -268,6 +268,8 @@ const renderEntry = (resource: Entry, boundaries: Boundaries, contextIdGenerator
resourceName = url.pathname.substring(url.pathname.lastIndexOf('/') + 1); resourceName = url.pathname.substring(url.pathname.lastIndexOf('/') + 1);
if (!resourceName) if (!resourceName)
resourceName = url.host; resourceName = url.host;
if (url.search)
resourceName += url.search;
} catch { } catch {
resourceName = resource.request.url; resourceName = resource.request.url;
} }

View file

@ -138,7 +138,7 @@ export async function setupSocksForwardingServer({
const socksProxy = new SocksProxy(); const socksProxy = new SocksProxy();
socksProxy.setPattern('*'); socksProxy.setPattern('*');
socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
if (!['127.0.0.1', '0:0:0:0:0:0:0:1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) { if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' }); socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return; return;
} }

View file

@ -53,7 +53,8 @@ const test = playwrightTest.extend<ExtraFixtures>({
const server = createHttpServer((req: http.IncomingMessage, res: http.ServerResponse) => { const server = createHttpServer((req: http.IncomingMessage, res: http.ServerResponse) => {
res.end('<html><body>from-dummy-server</body></html>'); res.end('<html><body>from-dummy-server</body></html>');
}); });
await new Promise<void>(resolve => server.listen(0, resolve)); // Only listen on IPv4 to check that we don't try to connect to it via IPv6.
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
await use((server.address() as net.AddressInfo).port); await use((server.address() as net.AddressInfo).port);
await new Promise<Error>(resolve => server.close(resolve)); await new Promise<Error>(resolve => server.close(resolve));
}, },
@ -792,9 +793,23 @@ for (const kind of ['launchServer', 'run-server'] as const) {
const remoteServer = await startRemoteServer(kind); const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: '*' } as any, dummyServerPort); const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: '*' } as any, dummyServerPort);
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto(`http://127.0.0.1:${examplePort}/foo.html`); {
expect(await page.content()).toContain('from-dummy-server'); await page.setContent('empty');
expect(reachedOriginalTarget).toBe(false); await page.goto(`http://127.0.0.1:${examplePort}/foo.html`);
expect(await page.content()).toContain('from-dummy-server');
expect(reachedOriginalTarget).toBe(false);
}
{
await page.setContent('empty');
await page.goto(`http://localhost:${examplePort}/foo.html`);
expect(await page.content()).toContain('from-dummy-server');
expect(reachedOriginalTarget).toBe(false);
}
{
const error = await page.goto(`http://[::1]:${examplePort}/foo.html`).catch(() => 'failed');
expect(error).toBe('failed');
expect(reachedOriginalTarget).toBe(false);
}
}); });
test('should proxy ipv6 localhost requests @smoke', async ({ startRemoteServer, server, browserName, connect, platform, ipV6ServerPort }, testInfo) => { test('should proxy ipv6 localhost requests @smoke', async ({ startRemoteServer, server, browserName, connect, platform, ipV6ServerPort }, testInfo) => {
@ -809,15 +824,26 @@ for (const kind of ['launchServer', 'run-server'] as const) {
const remoteServer = await startRemoteServer(kind); const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), { exposeNetwork: '*' }, ipV6ServerPort); const browser = await connect(remoteServer.wsEndpoint(), { exposeNetwork: '*' }, ipV6ServerPort);
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto(`http://[::1]:${examplePort}/foo.html`); {
expect(await page.content()).toContain('from-ipv6-server'); await page.setContent('empty');
const page2 = await browser.newPage(); await page.goto(`http://[::1]:${examplePort}/foo.html`);
await page2.goto(`http://localhost:${examplePort}/foo.html`); expect(await page.content()).toContain('from-ipv6-server');
expect(await page2.content()).toContain('from-ipv6-server'); expect(reachedOriginalTarget).toBe(false);
expect(reachedOriginalTarget).toBe(false); }
{
await page.setContent('empty');
await page.goto(`http://localhost:${examplePort}/foo.html`);
expect(await page.content()).toContain('from-ipv6-server');
expect(reachedOriginalTarget).toBe(false);
}
{
const error = await page.goto(`http://127.0.0.1:${examplePort}/foo.html`).catch(() => 'failed');
expect(error).toBe('failed');
expect(reachedOriginalTarget).toBe(false);
}
}); });
test('should proxy localhost requests from fetch api', async ({ startRemoteServer, server, browserName, connect, channel, platform, dummyServerPort }, workerInfo) => { test('should proxy requests from fetch api', async ({ startRemoteServer, server, browserName, connect, channel, platform, dummyServerPort }, workerInfo) => {
test.skip(browserName === 'webkit' && platform === 'darwin', 'no localhost proxying'); test.skip(browserName === 'webkit' && platform === 'darwin', 'no localhost proxying');
let reachedOriginalTarget = false; let reachedOriginalTarget = false;
@ -829,10 +855,54 @@ for (const kind of ['launchServer', 'run-server'] as const) {
const remoteServer = await startRemoteServer(kind); const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), { exposeNetwork: '*' }, dummyServerPort); const browser = await connect(remoteServer.wsEndpoint(), { exposeNetwork: '*' }, dummyServerPort);
const page = await browser.newPage(); const page = await browser.newPage();
const response = await page.request.get(`http://127.0.0.1:${examplePort}/foo.html`); {
expect(response.status()).toBe(200); const response = await page.request.get(`http://localhost:${examplePort}/foo.html`);
expect(await response.text()).toContain('from-dummy-server'); expect(response.status()).toBe(200);
expect(reachedOriginalTarget).toBe(false); expect(await response.text()).toContain('from-dummy-server');
expect(reachedOriginalTarget).toBe(false);
}
{
const response = await page.request.get(`http://127.0.0.1:${examplePort}/foo.html`);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('from-dummy-server');
expect(reachedOriginalTarget).toBe(false);
}
{
const error = await page.request.get(`http://[::1]:${examplePort}/foo.html`).catch(e => 'failed');
expect(error).toBe('failed');
expect(reachedOriginalTarget).toBe(false);
}
});
test('should proxy requests from fetch api over ipv6', async ({ startRemoteServer, server, browserName, connect, channel, platform, ipV6ServerPort }, workerInfo) => {
test.skip(browserName === 'webkit' && platform === 'darwin', 'no localhost proxying');
let reachedOriginalTarget = false;
server.setRoute('/foo.html', async (req, res) => {
reachedOriginalTarget = true;
res.end('<html><body></body></html>');
});
const examplePort = 20_000 + workerInfo.workerIndex * 3;
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint(), { exposeNetwork: '*' }, ipV6ServerPort);
const page = await browser.newPage();
{
const response = await page.request.get(`http://localhost:${examplePort}/foo.html`);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('from-ipv6-server');
expect(reachedOriginalTarget).toBe(false);
}
{
const response = await page.request.get(`http://[::1]:${examplePort}/foo.html`);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('from-ipv6-server');
expect(reachedOriginalTarget).toBe(false);
}
{
const error = await page.request.get(`http://127.0.0.1:${examplePort}/foo.html`).catch(e => 'failed');
expect(error).toBe('failed');
expect(reachedOriginalTarget).toBe(false);
}
}); });
test('should proxy local.playwright requests', async ({ connect, server, dummyServerPort, startRemoteServer }, workerInfo) => { test('should proxy local.playwright requests', async ({ connect, server, dummyServerPort, startRemoteServer }, workerInfo) => {

View file

@ -390,7 +390,7 @@ test.describe('browser', () => {
}); });
expect(connectHosts).toEqual([]); expect(connectHosts).toEqual([]);
await page.goto(serverURL); await page.goto(serverURL);
const host = browserName === 'webkit' && isMac ? '0:0:0:0:0:0:0:1' : '127.0.0.1'; const host = browserName === 'webkit' && isMac ? 'localhost' : '127.0.0.1';
expect(connectHosts).toEqual([`${host}:${serverPort}`]); expect(connectHosts).toEqual([`${host}:${serverPort}`]);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close(); await page.close();

View file

@ -24,9 +24,9 @@ import type { Log } from '../../packages/trace/src/har';
import { parseHar } from '../config/utils'; import { parseHar } from '../config/utils';
const { createHttp2Server } = require('../../packages/playwright-core/lib/utils'); const { createHttp2Server } = require('../../packages/playwright-core/lib/utils');
async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any, options: { outputPath?: string, proxy?: BrowserContextOptions['proxy'] } & Partial<Pick<BrowserContextOptions['recordHar'], 'content' | 'omitContent' | 'mode'>> = {}) { async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any, options: { outputPath?: string } & Partial<Pick<BrowserContextOptions['recordHar'], 'content' | 'omitContent' | 'mode'>> = {}) {
const harPath = testInfo.outputPath(options.outputPath || 'test.har'); const harPath = testInfo.outputPath(options.outputPath || 'test.har');
const context = await contextFactory({ recordHar: { path: harPath, ...options }, ignoreHTTPSErrors: true, proxy: options.proxy }); const context = await contextFactory({ recordHar: { path: harPath, ...options }, ignoreHTTPSErrors: true });
const page = await context.newPage(); const page = await context.newPage();
return { return {
page, page,
@ -861,38 +861,6 @@ it('should respect minimal mode for API Requests', async ({ contextFactory, serv
expect(entry.response.bodySize).toBe(-1); expect(entry.response.bodySize).toBe(-1);
}); });
it('should include timings when using http proxy', async ({ contextFactory, server, proxyServer }, testInfo) => {
proxyServer.forwardTo(server.PORT, { allowConnectRequests: true });
const { page, getLog } = await pageWithHar(contextFactory, testInfo, { proxy: { server: `localhost:${proxyServer.PORT}` } });
const response = await page.request.get(server.EMPTY_PAGE);
expect(proxyServer.connectHosts).toEqual([`localhost:${server.PORT}`]);
await expect(response).toBeOK();
const log = await getLog();
expect(log.entries[0].timings.connect).toBeGreaterThan(0);
});
it('should include timings when using socks proxy', async ({ contextFactory, server, socksPort }, testInfo) => {
const { page, getLog } = await pageWithHar(contextFactory, testInfo, { proxy: { server: `socks5://localhost:${socksPort}` } });
const response = await page.request.get(server.EMPTY_PAGE);
expect(await response.text()).toContain('Served by the SOCKS proxy');
await expect(response).toBeOK();
const log = await getLog();
expect(log.entries[0].timings.connect).toBeGreaterThan(0);
});
it('should not have connect and dns timings when socket is reused', async ({ contextFactory, server }, testInfo) => {
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
await page.request.get(server.EMPTY_PAGE);
await page.request.get(server.EMPTY_PAGE);
const log = await getLog();
expect(log.entries).toHaveLength(2);
const request2 = log.entries[1];
expect.soft(request2.timings.connect).toBe(-1);
expect.soft(request2.timings.dns).toBe(-1);
expect.soft(request2.timings.blocked).toBeGreaterThan(0);
});
it('should include redirects from API request', async ({ contextFactory, server }, testInfo) => { it('should include redirects from API request', async ({ contextFactory, server }, testInfo) => {
server.setRedirect('/redirect-me', '/simple.json'); server.setRedirect('/redirect-me', '/simple.json');
const { page, getLog } = await pageWithHar(contextFactory, testInfo); const { page, getLog } = await pageWithHar(contextFactory, testInfo);

View file

@ -778,6 +778,70 @@ await page.GetByText("link").ClickAsync();`);
expect(page.url()).toContain('about:blank#foo'); expect(page.url()).toContain('about:blank#foo');
}); });
test('should attribute navigation to press/fill', async ({ openRecorder }) => {
const { page, recorder } = await openRecorder();
await recorder.setContentAndWait(`<input /><script>document.querySelector('input').addEventListener('input', () => window.location.href = 'about:blank#foo');</script>`);
const locator = await recorder.hoverOverElement('input');
expect(locator).toBe(`getByRole('textbox')`);
await recorder.trustedClick();
await expect.poll(() => page.locator('input').evaluate(e => e === document.activeElement)).toBeTruthy();
const [, sources] = await Promise.all([
page.waitForNavigation(),
recorder.waitForOutput('JavaScript', '.fill'),
recorder.trustedPress('h'),
]);
expect.soft(sources.get('JavaScript')!.text).toContain(`
await page.goto('about:blank');
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill('h');
// ---------------------
await context.close();`);
expect.soft(sources.get('Playwright Test')!.text).toContain(`
await page.goto('about:blank');
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill('h');
});`);
expect.soft(sources.get('Java')!.text).toContain(`
page.navigate(\"about:blank\");
page.getByRole(AriaRole.TEXTBOX).click();
page.getByRole(AriaRole.TEXTBOX).fill(\"h\");
}`);
expect.soft(sources.get('Python')!.text).toContain(`
page.goto("about:blank")
page.get_by_role("textbox").click()
page.get_by_role("textbox").fill("h")
# ---------------------
context.close()`);
expect.soft(sources.get('Python Async')!.text).toContain(`
await page.goto("about:blank")
await page.get_by_role("textbox").click()
await page.get_by_role("textbox").fill("h")
# ---------------------
await context.close()`);
expect.soft(sources.get('Pytest')!.text).toContain(`
page.goto("about:blank")
page.get_by_role("textbox").click()
page.get_by_role("textbox").fill("h")`);
expect.soft(sources.get('C#')!.text).toContain(`
await page.GotoAsync("about:blank");
await page.GetByRole(AriaRole.Textbox).ClickAsync();
await page.GetByRole(AriaRole.Textbox).FillAsync("h");`);
expect(page.url()).toContain('about:blank#foo');
});
test('should ignore AltGraph', async ({ openRecorder, browserName }) => { test('should ignore AltGraph', async ({ openRecorder, browserName }) => {
test.skip(browserName === 'firefox', 'The TextInputProcessor in Firefox does not work with AltGraph.'); test.skip(browserName === 'firefox', 'The TextInputProcessor in Firefox does not work with AltGraph.');
const { recorder } = await openRecorder(); const { recorder } = await openRecorder();

View file

@ -218,6 +218,10 @@ export class Recorder {
await this.page.mouse.up(options); await this.page.mouse.up(options);
} }
async trustedPress(text: string) {
await this.page.keyboard.press(text);
}
async trustedDblclick() { async trustedDblclick() {
await this.page.mouse.down(); await this.page.mouse.down();
await this.page.mouse.up(); await this.page.mouse.up();

View file

@ -495,6 +495,21 @@ test('should not include hidden pseudo into accessible name', async ({ page }) =
expect.soft(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'hello hello' }); expect.soft(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'hello hello' });
}); });
test('should resolve pseudo content from attr', async ({ page }) => {
await page.setContent(`
<style>
.stars:before {
display: block;
content: attr(data-hello);
}
</style>
<a href="http://example.com">
<div class="stars" data-hello="hello">world</div>
</a>
`);
expect(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'hello world' });
});
test('should ignore invalid aria-labelledby', async ({ page }) => { test('should ignore invalid aria-labelledby', async ({ page }) => {
await page.setContent(` await page.setContent(`
<label> <label>

View file

@ -47,7 +47,6 @@ it('should fire for fetches with keepalive: true', {
description: 'https://github.com/microsoft/playwright/issues/34497' description: 'https://github.com/microsoft/playwright/issues/34497'
} }
}, async ({ page, server, browserName }) => { }, async ({ page, server, browserName }) => {
it.fixme(browserName === 'firefox');
const requests = []; const requests = [];
page.on('request', request => requests.push(request)); page.on('request', request => requests.push(request));
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);

View file

@ -22,12 +22,7 @@ test.describe.configure({ mode: 'parallel' });
test('should match snapshot with name', async ({ runInlineTest }, testInfo) => { test('should match snapshot with name', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'a.spec.ts-snapshots/test.yml': `
export default {
snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}',
};
`,
'__snapshots__/a.spec.ts/test.yml': `
- heading "hello world" - heading "hello world"
`, `,
'a.spec.ts': ` 'a.spec.ts': `
@ -44,11 +39,6 @@ test('should match snapshot with name', async ({ runInlineTest }, testInfo) => {
test('should generate multiple missing', async ({ runInlineTest }, testInfo) => { test('should generate multiple missing', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': `
export default {
snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}',
};
`,
'a.spec.ts': ` 'a.spec.ts': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('test', async ({ page }) => { test('test', async ({ page }) => {
@ -61,25 +51,20 @@ test('should generate multiple missing', async ({ runInlineTest }, testInfo) =>
}); });
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.output).toContain(`A snapshot doesn't exist at __snapshots__${path.sep}a.spec.ts${path.sep}test-1.yml, writing actual`); expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at __snapshots__${path.sep}a.spec.ts${path.sep}test-2.yml, writing actual`); expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-1.yml'), 'utf8'); const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]'); expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-2.yml'), 'utf8'); const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
}); });
test('should rebaseline all', async ({ runInlineTest }, testInfo) => { test('should rebaseline all', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'a.spec.ts-snapshots/test-1.yml': `
export default {
snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}',
};
`,
'__snapshots__/a.spec.ts/test-1.yml': `
- heading "foo" - heading "foo"
`, `,
'__snapshots__/a.spec.ts/test-2.yml': ` 'a.spec.ts-snapshots/test-2.yml': `
- heading "bar" - heading "bar"
`, `,
'a.spec.ts': ` 'a.spec.ts': `
@ -94,22 +79,17 @@ test('should rebaseline all', async ({ runInlineTest }, testInfo) => {
}, { 'update-snapshots': 'all' }); }, { 'update-snapshots': 'all' });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.output).toContain(`A snapshot is generated at __snapshots__${path.sep}a.spec.ts${path.sep}test-1.yml`); expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml`);
expect(result.output).toContain(`A snapshot is generated at __snapshots__${path.sep}a.spec.ts${path.sep}test-2.yml`); expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.yml`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-1.yml'), 'utf8'); const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]'); expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-2.yml'), 'utf8'); const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
}); });
test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => { test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'a.spec.ts-snapshots/test.yml': `
export default {
snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}',
};
`,
'__snapshots__/a.spec.ts/test.yml': `
- heading "hello world" - heading "hello world"
`, `,
'a.spec.ts': ` 'a.spec.ts': `
@ -122,17 +102,12 @@ test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => {
}, { 'update-snapshots': 'changed' }); }, { 'update-snapshots': 'changed' });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test.yml'), 'utf8'); const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.yml'), 'utf8');
expect(snapshot1.trim()).toBe('- heading "hello world"'); expect(snapshot1.trim()).toBe('- heading "hello world"');
}); });
test('should generate snapshot name', async ({ runInlineTest }, testInfo) => { test('should generate snapshot name', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': `
export default {
snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}',
};
`,
'a.spec.ts': ` 'a.spec.ts': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('test name', async ({ page }) => { test('test name', async ({ page }) => {
@ -145,11 +120,11 @@ test('should generate snapshot name', async ({ runInlineTest }, testInfo) => {
}); });
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.output).toContain(`A snapshot doesn't exist at __snapshots__${path.sep}a.spec.ts${path.sep}test-name-1.yml, writing actual`); expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at __snapshots__${path.sep}a.spec.ts${path.sep}test-name-2.yml, writing actual`); expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-name-1.yml'), 'utf8'); const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]'); expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('__snapshots__/a.spec.ts/test-name-2.yml'), 'utf8'); const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
}); });
@ -158,7 +133,6 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'playwright.config.ts': `
export default { export default {
snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}',
updateSnapshots: '${updateSnapshots}', updateSnapshots: '${updateSnapshots}',
}; };
`, `,
@ -169,13 +143,13 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) {
await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 }); await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 });
}); });
`, `,
'__snapshots__/a.spec.ts/test-1.yml': '- heading "Old content" [level=1]', 'a.spec.ts-snapshots/test-1.yml': '- heading "Old content" [level=1]',
}); });
const rebase = updateSnapshots === 'all' || updateSnapshots === 'changed'; const rebase = updateSnapshots === 'all' || updateSnapshots === 'changed';
expect(result.exitCode).toBe(rebase ? 0 : 1); expect(result.exitCode).toBe(rebase ? 0 : 1);
if (rebase) { if (rebase) {
const snapshotOutputPath = testInfo.outputPath('__snapshots__/a.spec.ts/test-1.yml'); const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.yml');
expect(result.output).toContain(`A snapshot is generated at`); expect(result.output).toContain(`A snapshot is generated at`);
const data = fs.readFileSync(snapshotOutputPath); const data = fs.readFileSync(snapshotOutputPath);
expect(data.toString()).toBe('- heading "New content" [level=1]'); expect(data.toString()).toBe('- heading "New content" [level=1]');
@ -187,14 +161,6 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) {
test('should respect timeout', async ({ runInlineTest }, testInfo) => { test('should respect timeout', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': `
export default {
snapshotPathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}',
};
`,
'test.yml': `
- heading "hello world"
`,
'a.spec.ts': ` 'a.spec.ts': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import path from 'path'; import path from 'path';
@ -203,9 +169,61 @@ test('should respect timeout', async ({ runInlineTest }, testInfo) => {
await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 }); await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 });
}); });
`, `,
'__snapshots__/a.spec.ts/test-1.yml': '- heading "new world" [level=1]', 'a.spec.ts-snapshots/test-1.yml': '- heading "new world" [level=1]',
}); });
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.output).toContain(`Timed out 1ms waiting for`); expect(result.output).toContain(`Timed out 1ms waiting for`);
}); });
test('should respect config.snapshotPathTemplate', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': `
export default {
snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}',
};
`,
'my-snapshots/dir/a.spec.ts/test.yml': `
- heading "hello world"
`,
'dir/a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
});
`
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should respect config.expect.toMatchAriaSnapshot.pathTemplate', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': `
export default {
snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}',
expect: {
toMatchAriaSnapshot: {
pathTemplate: 'actual-snapshots/{testFilePath}/{arg}{ext}',
},
},
};
`,
'my-snapshots/dir/a.spec.ts/test.yml': `
- heading "wrong one"
`,
'actual-snapshots/dir/a.spec.ts/test.yml': `
- heading "hello world"
`,
'dir/a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
});
`
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});

View file

@ -978,8 +978,8 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await test.step('step', async () => { await test.step('step', async () => {
testInfo.attachments.push({ name: 'attachment', body: 'content', contentType: 'text/plain' }); testInfo.attachments.push({ name: 'attachment', body: 'content', contentType: 'text/plain' });
}) })
}); });
`, `,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
@ -1095,7 +1095,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest({ const result = await runInlineTest({
'a.spec.js': ` 'a.spec.js': `
import { test as base, expect } from '@playwright/test'; import { test as base, expect } from '@playwright/test';
const test = base.extend({ const test = base.extend({
fixture1: [async ({}, use) => { fixture1: [async ({}, use) => {
await use(); await use();
@ -1141,144 +1141,97 @@ for (const useIntermediateMergeReport of [true, false] as const) {
]); ]);
}); });
test.describe('gitCommitInfo plugin', () => { test('should include metadata with populateGitInfo = true', async ({ runInlineTest, writeFiles, showReport, page }) => {
test('should include metadata', async ({ runInlineTest, writeFiles, showReport, page }) => { const files = {
const files = { 'uncommitted.txt': `uncommitted file`,
'uncommitted.txt': `uncommitted file`, 'playwright.config.ts': `
'playwright.config.ts': ` export default {
import { gitCommitInfo } from 'playwright/lib/plugins'; populateGitInfo: true,
import { test, expect } from '@playwright/test'; metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
const plugins = [gitCommitInfo()]; };
export default { '@playwright/test': { plugins } }; `,
`, 'example.spec.ts': `
'example.spec.ts': ` import { test, expect } from '@playwright/test';
import { test, expect } from '@playwright/test'; test('sample', async ({}) => { expect(2).toBe(2); });
test('sample', async ({}) => { expect(2).toBe(2); }); `,
`, };
}; const baseDir = await writeFiles(files);
const baseDir = await writeFiles(files);
const execGit = async (args: string[]) => { const execGit = async (args: string[]) => {
const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir }); const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir });
if (!!code) if (!!code)
throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`); throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`);
return; return;
}; };
await execGit(['init']); await execGit(['init']);
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']); await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']);
await execGit(['config', '--local', 'user.name', 'William']); await execGit(['config', '--local', 'user.name', 'William']);
await execGit(['add', '*.ts']); await execGit(['add', '*.ts']);
await execGit(['commit', '-m', 'awesome commit message']); await execGit(['commit', '-m', 'chore(html): make this test look nice']);
const result = await runInlineTest(files, { reporter: 'dot,html' }, { const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never', PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_RUN_ID: 'example-run-id', GITHUB_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SERVER_URL: 'https://playwright.dev',
GITHUB_SHA: 'example-sha', GITHUB_SHA: 'example-sha',
});
await showReport();
expect(result.exitCode).toBe(0);
await page.click('text=awesome commit message');
await expect.soft(page.getByTestId('revision.id')).toContainText(/^[a-f\d]+$/i);
await expect.soft(page.getByTestId('revision.id').locator('a')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha');
await expect.soft(page.getByTestId('revision.timestamp')).toContainText(/AM|PM/);
await expect.soft(page.locator('text=awesome commit message')).toHaveCount(2);
await expect.soft(page.locator('text=William')).toBeVisible();
await expect.soft(page.locator('text=shakespeare@example.local')).toBeVisible();
await expect.soft(page.locator('text=CI/CD Logs')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/actions/runs/example-run-id');
await expect.soft(page.locator('text=Report generated on')).toContainText(/AM|PM/);
await expect.soft(page.getByTestId('metadata-chip')).toBeVisible();
await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible();
}); });
await showReport();
test('should use explicitly supplied metadata', async ({ runInlineTest, showReport, page }) => { expect(result.exitCode).toBe(0);
const result = await runInlineTest({ await page.getByRole('button', { name: 'Metadata' }).click();
'uncommitted.txt': `uncommitted file`, await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
'playwright.config.ts': ` - 'link "chore(html): make this test look nice"'
import { gitCommitInfo } from 'playwright/lib/plugins'; - text: /^William <shakespeare@example.local> on/
import { test, expect } from '@playwright/test'; - link "logs"
const plugin = gitCommitInfo({ - link /^[a-f0-9]{7}$/
info: { - text: 'foo: value1 bar: {"prop":"value2"} baz: ["value3",123]'
'revision.id': '1234567890', `);
'revision.subject': 'a better subject', });
'revision.timestamp': new Date(),
'revision.author': 'William',
'revision.email': 'shakespeare@example.local',
},
});
export default { '@playwright/test': { plugins: [plugin] } };
`,
'example.spec.ts': `
import { gitCommitInfo } from 'playwright/lib/plugins';
import { test, expect } from '@playwright/test';
test('sample', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_RUN_ID: 'example-run-id', GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SHA: 'example-sha' }, undefined);
await showReport(); test('should not include git metadata with populateGitInfo = false', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'playwright.config.ts': `
export default { populateGitInfo: false };
`,
'example.spec.ts': `
import { test, expect } from '@playwright/test';
test('my sample test', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }, undefined);
expect(result.exitCode).toBe(0); await showReport();
await page.click('text=a better subject');
await expect.soft(page.getByTestId('revision.id')).toContainText(/^[a-f\d]+$/i);
await expect.soft(page.getByTestId('revision.id').locator('a')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha');
await expect.soft(page.getByTestId('revision.timestamp')).toContainText(/AM|PM/);
await expect.soft(page.locator('text=a better subject')).toHaveCount(2);
await expect.soft(page.locator('text=William')).toBeVisible();
await expect.soft(page.locator('text=shakespeare@example.local')).toBeVisible();
await expect.soft(page.locator('text=CI/CD Logs')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/actions/runs/example-run-id');
await expect.soft(page.locator('text=Report generated on')).toContainText(/AM|PM/);
await expect.soft(page.getByTestId('metadata-chip')).toBeVisible();
await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible();
});
test('should not have metadata by default', async ({ runInlineTest, showReport, page }) => { expect(result.exitCode).toBe(0);
const result = await runInlineTest({ await expect.soft(page.getByRole('button', { name: 'Metadata' })).toBeHidden();
'uncommitted.txt': `uncommitted file`, await expect.soft(page.locator('.metadata-view')).toBeHidden();
'playwright.config.ts': ` });
export default {};
`,
'example.spec.ts': `
import { test, expect } from '@playwright/test';
test('my sample test', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }, undefined);
await showReport(); test('should show an error when metadata has invalid fields', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
export default {
metadata: {
'git.commit.info': { 'revision.timestamp': 'hi' }
},
};
`,
'example.spec.ts': `
import { test, expect } from '@playwright/test';
test('my sample test', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
expect(result.exitCode).toBe(0); await showReport();
await expect.soft(page.locator('text="my sample test"')).toBeVisible();
await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible();
await expect.soft(page.getByTestId('metadata-chip')).not.toBeVisible();
});
test('should not include metadata if user supplies invalid values via metadata field', async ({ runInlineTest, showReport, page }) => { expect(result.exitCode).toBe(0);
const result = await runInlineTest({ await page.getByRole('button', { name: 'Metadata' }).click();
'uncommitted.txt': `uncommitted file`, await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
'playwright.config.ts': ` - paragraph: An error was encountered when trying to render metadata.
export default { `);
metadata: {
'revision.timestamp': 'hi',
},
};
`,
'example.spec.ts': `
import { test, expect } from '@playwright/test';
test('my sample test', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
await showReport();
expect(result.exitCode).toBe(0);
await expect.soft(page.locator('text="my sample test"')).toBeVisible();
await expect.soft(page.getByTestId('metadata-error')).toBeVisible();
await expect.soft(page.getByTestId('metadata-chip')).not.toBeVisible();
});
}); });
test('should report clashing folders', async ({ runInlineTest, useIntermediateMergeReport }) => { test('should report clashing folders', async ({ runInlineTest, useIntermediateMergeReport }) => {

View file

@ -740,6 +740,25 @@ test('should update snapshot with the update-snapshots flag', async ({ runInline
expect(comparePNGs(fs.readFileSync(snapshotOutputPath), whiteImage)).toBe(null); expect(comparePNGs(fs.readFileSync(snapshotOutputPath), whiteImage)).toBe(null);
}); });
test('should respect config.expect.toHaveScreenshot.pathTemplate', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...playwrightConfig({
snapshotPathTemplate: '__screenshots__/{testFilePath}/{arg}{ext}',
expect: { toHaveScreenshot: { pathTemplate: 'actual-screenshots/{testFilePath}/{arg}{ext}' } },
}),
'__screenshots__/a.spec.js/snapshot.png': blueImage,
'actual-screenshots/a.spec.js/snapshot.png': whiteImage,
'a.spec.js': `
const { test, expect } = require('@playwright/test');
test('is a test', async ({ page }) => {
await expect(page).toHaveScreenshot('snapshot.png');
});
`
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('shouldn\'t update snapshot with the update-snapshots flag for negated matcher', async ({ runInlineTest }, testInfo) => { test('shouldn\'t update snapshot with the update-snapshots flag for negated matcher', async ({ runInlineTest }, testInfo) => {
const EXPECTED_SNAPSHOT = blueImage; const EXPECTED_SNAPSHOT = blueImage;
const result = await runInlineTest({ const result = await runInlineTest({

View file

@ -0,0 +1,48 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './ui-mode-fixtures';
test('should render html report git info metadata', async ({ runUITest }) => {
const { page } = await runUITest({
'reporter.ts': `
module.exports = class Reporter {
onBegin(config, suite) {
console.log('ci.link:', config.metadata['git.commit.info']['ci.link']);
}
}
`,
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';
export default defineConfig({
populateGitInfo: true,
reporter: './reporter.ts',
});
`,
'a.test.js': `
import { test, expect } from '@playwright/test';
test('should work', async ({}) => {});
`
}, {
BUILD_URL: 'https://playwright.dev',
});
await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)');
await page.getByTitle('Toggle output').click();
await expect(page.getByTestId('output')).toContainText('ci.link: https://playwright.dev');
});