Merge branch 'main' into sk-branch-2

This commit is contained in:
Simon Knott 2024-11-04 10:28:53 +01:00
commit 110312cf62
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
190 changed files with 2353 additions and 2204 deletions

View file

@ -1,6 +1,6 @@
# 🎭 Playwright # 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-131.0.6778.3-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-131.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) [![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-131.0.6778.24-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-131.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord)
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr
| | Linux | macOS | Windows | | | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: | | :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->131.0.6778.3<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Chromium <!-- GEN:chromium-version -->131.0.6778.24<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->18.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit <!-- GEN:webkit-version -->18.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->131.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox <!-- GEN:firefox-version -->131.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

View file

@ -104,38 +104,23 @@ await page.Keyboard.PressAsync("Shift+A");
An example to trigger select-all with the keyboard An example to trigger select-all with the keyboard
```js ```js
// on Windows and Linux await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Control+A');
// on macOS
await page.keyboard.press('Meta+A');
``` ```
```java ```java
// on Windows and Linux page.keyboard().press("ControlOrMeta+A");
page.keyboard().press("Control+A");
// on macOS
page.keyboard().press("Meta+A");
``` ```
```python async ```python async
# on windows and linux await page.keyboard.press("ControlOrMeta+A")
await page.keyboard.press("Control+A")
# on mac_os
await page.keyboard.press("Meta+A")
``` ```
```python sync ```python sync
# on windows and linux page.keyboard.press("ControlOrMeta+A")
page.keyboard.press("Control+A")
# on mac_os
page.keyboard.press("Meta+A")
``` ```
```csharp ```csharp
// on Windows and Linux await page.Keyboard.PressAsync("ControlOrMeta+A");
await page.Keyboard.PressAsync("Control+A");
// on macOS
await page.Keyboard.PressAsync("Meta+A");
``` ```
## async method: Keyboard.down ## async method: Keyboard.down

View file

@ -90,7 +90,7 @@ await page
#### Prefer user-facing attributes to XPath or CSS selectors #### Prefer user-facing attributes to XPath or CSS selectors
Your DOM can easily change so having your tests depend on your DOM structure can lead to failing tests. For example consider selecting this button by its CSS classes. Should the designer change something then the class might change breaking your test. Your DOM can easily change so having your tests depend on your DOM structure can lead to failing tests. For example consider selecting this button by its CSS classes. Should the designer change something then the class might change, thus breaking your test.
```js ```js

View file

@ -401,6 +401,23 @@ pytest test_login.py --browser-channel msedge
dotnet test -- Playwright.BrowserName=chromium Playwright.LaunchOptions.Channel=msedge dotnet test -- Playwright.BrowserName=chromium Playwright.LaunchOptions.Channel=msedge
``` ```
######
* langs: python
Alternatively when using the library directly, you can specify the browser [`option: BrowserType.launch.channel`] when launching the browser:
```python
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# Channel can be "chrome", "msedge", "chrome-beta", "msedge-beta" or "msedge-dev".
browser = p.chromium.launch(channel="msedge")
page = browser.new_page()
page.goto("http://playwright.dev")
print(page.title())
browser.close()
```
#### Installing Google Chrome & Microsoft Edge #### Installing Google Chrome & Microsoft Edge
If Google Chrome or Microsoft Edge is not available on your machine, you can install If Google Chrome or Microsoft Edge is not available on your machine, you can install

View file

@ -4,6 +4,12 @@
Information about an error thrown during test execution. Information about an error thrown during test execution.
## property: TestInfoError.cause
* since: v1.49
- type: ?<[TestInfoError]>
Error cause. Set when there is a [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error].
## property: TestInfoError.message ## property: TestInfoError.message
* since: v1.10 * since: v1.10
- type: ?<[string]> - type: ?<[string]>

View file

@ -521,7 +521,6 @@ You can use `beforeMount` and `afterMount` hooks to configure your app. This let
{label: 'React', value: 'react'}, {label: 'React', value: 'react'},
{label: 'Solid', value: 'solid'}, {label: 'Solid', value: 'solid'},
{label: 'Vue3', value: 'vue3'}, {label: 'Vue3', value: 'vue3'},
{label: 'Vue2', value: 'vue2'},
] ]
}> }>
<TabItem value="react"> <TabItem value="react">
@ -617,40 +616,6 @@ You can use `beforeMount` and `afterMount` hooks to configure your app. This let
</TabItem> </TabItem>
<TabItem value="vue2">
```js title="playwright/index.ts"
import { beforeMount, afterMount } from '@playwright/experimental-ct-vue2/hooks';
import Router from 'vue-router';
import { router } from '../src/router';
export type HooksConfig = {
enableRouting?: boolean;
}
beforeMount<HooksConfig>(async ({ app, hooksConfig }) => {
if (hooksConfig?.enableRouting) {
Vue.use(Router);
return { router }
}
});
```
```js title="src/pages/ProductsPage.spec.ts"
import { test, expect } from '@playwright/experimental-ct-vue2';
import type { HooksConfig } from '../playwright';
import ProductsPage from './pages/ProductsPage.vue';
test('configure routing through hooks config', async ({ page, mount }) => {
const component = await mount<HooksConfig>(ProductsPage, {
hooksConfig: { enableRouting: true },
});
await expect(component.getByRole('link')).toHaveAttribute('href', '/products/42');
});
```
</TabItem>
</Tabs> </Tabs>
### unmount ### unmount

View file

@ -4,29 +4,11 @@
Information about an error thrown during test execution. Information about an error thrown during test execution.
## property: TestError.expected ## property: TestError.cause
* since: v1.49 * since: v1.49
- type: ?<[string]> - type: ?<[TestError]>
Expected value formatted as a human-readable string. Error cause. Set when there is a [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error].
## property: TestError.locator
* since: v1.49
- type: ?<[string]>
Receiver's locator.
## property: TestError.log
* since: v1.49
- type: ?<[Array]<[string]>>
Call log.
## property: TestError.matcherName
* since: v1.49
- type: ?<[string]>
Expect matcher name.
## property: TestError.message ## property: TestError.message
* since: v1.10 * since: v1.10
@ -34,24 +16,12 @@ Expect matcher name.
Error message. Set when [Error] (or its subclass) has been thrown. Error message. Set when [Error] (or its subclass) has been thrown.
## property: TestError.received
* since: v1.49
- type: ?<[string]>
Received value formatted as a human-readable string.
## property: TestError.stack ## property: TestError.stack
* since: v1.10 * since: v1.10
- type: ?<[string]> - type: ?<[string]>
Error stack. Set when [Error] (or its subclass) has been thrown. Error stack. Set when [Error] (or its subclass) has been thrown.
## property: TestError.timeout
* since: v1.49
- type: ?<[int]>
Timeout in milliseconds, if the error was caused by a timeout.
## property: TestError.value ## property: TestError.value
* since: v1.10 * since: v1.10
- type: ?<[string]> - type: ?<[string]>

View file

@ -99,7 +99,7 @@ See [Running Tests](./running-tests.md) for general information on `pytest` opti
## Examples ## Examples
### Configure Mypy typings for auto-completion ### Configure typings for auto-completion
```py title="test_my_application.py" ```py title="test_my_application.py"
from playwright.sync_api import Page from playwright.sync_api import Page
@ -109,16 +109,23 @@ def test_visit_admin_dashboard(page: Page):
# ... # ...
``` ```
### Configure slow mo If you're using VSCode with Pylance, these types can be inferred by enabling the `python.testing.pytestEnabled` setting so you don't need the type annotation.
Run tests with slow mo with the `--slowmo` argument. ### Using multiple contexts
```bash In order to simulate multiple users, you can create multiple [`BrowserContext`](./browser-contexts) instances.
pytest --slowmo 100
```py title="test_my_application.py"
from playwright.sync_api import Page, BrowserContext
from pytest_playwright.pytest_playwright import CreateContextCallback
def test_foo(page: Page, new_context: CreateContextCallback) -> None:
page.goto("https://example.com")
context = new_context()
page2 = context.new_page()
# page and page2 are in different contexts
``` ```
Slows down Playwright operations by 100 milliseconds.
### Skip test by browser ### Skip test by browser
```py title="test_my_application.py" ```py title="test_my_application.py"
@ -196,7 +203,7 @@ def browser_context_args(browser_context_args):
} }
``` ```
### Device emulation ### Device emulation / BrowserContext option overrides
```py title="conftest.py" ```py title="conftest.py"
import pytest import pytest

107
package-lock.json generated
View file

@ -1508,10 +1508,6 @@
"resolved": "packages/playwright-ct-vue", "resolved": "packages/playwright-ct-vue",
"link": true "link": true
}, },
"node_modules/@playwright/experimental-ct-vue2": {
"resolved": "packages/playwright-ct-vue2",
"link": true
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"resolved": "packages/playwright-test", "resolved": "packages/playwright-test",
"link": true "link": true
@ -2416,28 +2412,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true "dev": true
}, },
"node_modules/ansi-to-html": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
"dependencies": {
"entities": "^2.2.0"
},
"bin": {
"ansi-to-html": "bin/ansi-to-html"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ansi-to-html/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/anymatch": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@ -5964,21 +5938,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
"optional": true,
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/progress": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@ -6593,14 +6552,6 @@
"solid-js": "^1.3" "solid-js": "^1.3"
} }
}, },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -7922,10 +7873,7 @@
} }
}, },
"packages/html-reporter": { "packages/html-reporter": {
"version": "0.0.0", "version": "0.0.0"
"dependencies": {
"ansi-to-html": "^0.7.2"
}
}, },
"packages/playwright": { "packages/playwright": {
"version": "1.49.0-next", "version": "1.49.0-next",
@ -8097,59 +8045,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"packages/playwright-ct-vue2": {
"name": "@playwright/experimental-ct-vue2",
"version": "1.49.0-next",
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.49.0-next",
"@vitejs/plugin-vue2": "^2.2.0"
},
"bin": {
"playwright": "cli.js"
},
"devDependencies": {
"vue": "^2.7.14"
},
"engines": {
"node": ">=18"
}
},
"packages/playwright-ct-vue2/node_modules/@vitejs/plugin-vue2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue2/-/plugin-vue2-2.3.1.tgz",
"integrity": "sha512-/ksaaz2SRLN11JQhLdEUhDzOn909WEk99q9t9w+N12GjQCljzv7GyvAbD/p20aBUjHkvpGOoQ+FCOkG+mjDF4A==",
"engines": {
"node": "^14.18.0 || >= 16.0.0"
},
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0",
"vue": "^2.7.0-0"
}
},
"packages/playwright-ct-vue2/node_modules/@vue/compiler-sfc": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz",
"integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==",
"dependencies": {
"@babel/parser": "^7.23.5",
"postcss": "^8.4.14",
"source-map": "^0.6.1"
},
"optionalDependencies": {
"prettier": "^1.18.2 || ^2.0.0"
}
},
"packages/playwright-ct-vue2/node_modules/vue": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz",
"integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==",
"deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.",
"dependencies": {
"@vue/compiler-sfc": "2.7.16",
"csstype": "^3.1.0"
}
},
"packages/playwright-firefox": { "packages/playwright-firefox": {
"version": "1.49.0-next", "version": "1.49.0-next",
"hasInstallScript": true, "hasInstallScript": true,

View file

@ -7,8 +7,5 @@
"dev": "vite", "dev": "vite",
"build": "vite build && tsc", "build": "vite build && tsc",
"preview": "vite preview" "preview": "vite preview"
},
"dependencies": {
"ansi-to-html": "^0.7.2"
} }
} }

View file

@ -48,3 +48,14 @@ test('setExpanded is called', async ({ mount }) => {
await component.getByText('Title').click(); await component.getByText('Title').click();
expect(expandedValues).toEqual([true]); expect(expandedValues).toEqual([true]);
}); });
test('setExpanded should work', async ({ mount }) => {
const component = await mount(<AutoChip header='Title' initialExpanded={false}>
Body
</AutoChip>);
await component.getByText('Title').click();
await expect(component).toMatchAriaSnapshot(`
- button "Title" [expanded]
- region: Body
`);
});

View file

@ -30,8 +30,12 @@ export const Chip: React.FC<{
dataTestId?: string, dataTestId?: string,
targetRef?: React.RefObject<HTMLDivElement>, targetRef?: React.RefObject<HTMLDivElement>,
}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => { }> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => {
const id = React.useId();
return <div className='chip' data-testid={dataTestId} ref={targetRef}> return <div className='chip' data-testid={dataTestId} ref={targetRef}>
<div <div
role='button'
aria-expanded={!!expanded}
aria-controls={id}
className={clsx('chip-header', setExpanded && ' expanded-' + expanded)} className={clsx('chip-header', setExpanded && ' expanded-' + expanded)}
onClick={() => setExpanded?.(!expanded)} onClick={() => setExpanded?.(!expanded)}
title={typeof header === 'string' ? header : undefined}> title={typeof header === 'string' ? header : undefined}>
@ -39,7 +43,7 @@ export const Chip: React.FC<{
{setExpanded && !expanded && icons.rightArrow()} {setExpanded && !expanded && icons.rightArrow()}
{header} {header}
</div> </div>
{(!setExpanded || expanded) && <div className={clsx('chip-body', noInsets && 'chip-body-no-insets')}>{children}</div>} {(!setExpanded || expanded) && <div id={id} role='region' className={clsx('chip-body', noInsets && 'chip-body-no-insets')}>{children}</div>}
</div>; </div>;
}; };

View file

@ -33,6 +33,11 @@ test('should render counters', async ({ mount }) => {
await expect(component.locator('a', { hasText: 'Failed' }).locator('.counter')).toHaveText('31'); await expect(component.locator('a', { hasText: 'Failed' }).locator('.counter')).toHaveText('31');
await expect(component.locator('a', { hasText: 'Flaky' }).locator('.counter')).toHaveText('17'); await expect(component.locator('a', { hasText: 'Flaky' }).locator('.counter')).toHaveText('17');
await expect(component.locator('a', { hasText: 'Skipped' }).locator('.counter')).toHaveText('10'); await expect(component.locator('a', { hasText: 'Skipped' }).locator('.counter')).toHaveText('10');
await expect(component).toMatchAriaSnapshot(`
- navigation:
- link "All 90"
- text: Passed 42 Failed 31 Flaky 17 Skipped 10
`);
}); });
test('should toggle filters', async ({ page, mount }) => { test('should toggle filters', async ({ page, mount }) => {

View file

@ -20,7 +20,7 @@ import './colors.css';
import './common.css'; import './common.css';
import './headerView.css'; import './headerView.css';
import * as icons from './icons'; import * as icons from './icons';
import { Link, navigate } from './links'; import { Link, navigate, SearchParamsContext } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import { filterWithToken } from './filter'; import { filterWithToken } from './filter';
@ -65,7 +65,7 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
const StatsNavView: React.FC<{ const StatsNavView: React.FC<{
stats: Stats stats: Stats
}> = ({ stats }) => { }> = ({ stats }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1)); const searchParams = React.useContext(SearchParamsContext);
const q = searchParams.get('q')?.toString() || ''; const q = searchParams.get('q')?.toString() || '';
const tokens = q.split(' '); const tokens = q.split(' ');
return <nav> return <nav>

View file

@ -27,6 +27,7 @@ import { ReportView } from './reportView';
const zipjs = zipImport as typeof zip; const zipjs = zipImport as typeof zip;
import logo from '@web/assets/playwright-logo.svg'; import logo from '@web/assets/playwright-logo.svg';
import { SearchParamsProvider } from './links';
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'shortcut icon'; link.rel = 'shortcut icon';
link.href = logo; link.href = logo;
@ -40,7 +41,9 @@ const ReportLoader: React.FC = () => {
const zipReport = new ZipReport(); const zipReport = new ZipReport();
zipReport.load().then(() => setReport(zipReport)); zipReport.load().then(() => setReport(zipReport));
}, [report]); }, [report]);
return <ReportView report={report}></ReportView>; return <SearchParamsProvider>
<ReportView report={report} />
</SearchParamsProvider>;
}; };
window.onload = () => { window.onload = () => {

View file

@ -33,13 +33,8 @@ export const Route: React.FunctionComponent<{
predicate: (params: URLSearchParams) => boolean, predicate: (params: URLSearchParams) => boolean,
children: any children: any
}> = ({ predicate, children }) => { }> = ({ predicate, children }) => {
const [matches, setMatches] = React.useState(predicate(new URLSearchParams(window.location.hash.slice(1)))); const searchParams = React.useContext(SearchParamsContext);
React.useEffect(() => { return predicate(searchParams) ? children : null;
const listener = () => setMatches(predicate(new URLSearchParams(window.location.hash.slice(1))));
window.addEventListener('popstate', listener);
return () => window.removeEventListener('popstate', listener);
}, [predicate]);
return matches ? children : null;
}; };
export const Link: React.FunctionComponent<{ export const Link: React.FunctionComponent<{
@ -90,6 +85,20 @@ export const AttachmentLink: React.FunctionComponent<{
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>; } : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
}; };
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
export const SearchParamsProvider: React.FunctionComponent<React.PropsWithChildren> = ({ children }) => {
const [searchParams, setSearchParams] = React.useState<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
React.useEffect(() => {
const listener = () => setSearchParams(new URLSearchParams(window.location.hash.slice(1)));
window.addEventListener('popstate', listener);
return () => window.removeEventListener('popstate', listener);
}, []);
return <SearchParamsContext.Provider value={searchParams}>{children}</SearchParamsContext.Provider>;
};
function downloadFileNameForAttachment(attachment: TestAttachment): string { function downloadFileNameForAttachment(attachment: TestAttachment): string {
if (attachment.name.includes('.') || !attachment.path) if (attachment.name.includes('.') || !attachment.path)
return attachment.name; return attachment.name;

View file

@ -14,19 +14,19 @@
limitations under the License. limitations under the License.
*/ */
import type { FilteredStats, TestCase, TestFile, TestFileSummary } from './types'; import type { FilteredStats, TestCase, TestCaseSummary, TestFile, TestFileSummary } from './types';
import * as React from 'react'; import * as React from 'react';
import './colors.css'; import './colors.css';
import './common.css'; import './common.css';
import { Filter } from './filter'; import { Filter } from './filter';
import { HeaderView } from './headerView'; import { HeaderView } from './headerView';
import { Route } 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 type { Metainfo } from './metadataView';
import { MetadataView } from './metadataView'; import { MetadataView } from './metadataView';
import { TestCaseView } from './testCaseView'; import { TestCaseView } from './testCaseView';
import { TestFilesView } from './testFilesView'; import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css'; import './theme.css';
declare global { declare global {
@ -39,32 +39,55 @@ declare global {
const testFilesRoutePredicate = (params: URLSearchParams) => !params.has('testId'); const testFilesRoutePredicate = (params: URLSearchParams) => !params.has('testId');
const testCaseRoutePredicate = (params: URLSearchParams) => params.has('testId'); const testCaseRoutePredicate = (params: URLSearchParams) => params.has('testId');
type TestModelSummary = {
files: TestFileSummary[];
tests: TestCaseSummary[];
};
export const ReportView: React.FC<{ export const ReportView: React.FC<{
report: LoadedReport | undefined, report: LoadedReport | undefined,
}> = ({ report }) => { }> = ({ report }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1)); 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 testIdToFileIdMap = React.useMemo(() => {
const map = new Map<string, string>();
for (const file of report?.json().files || []) {
for (const test of file.tests)
map.set(test.testId, file.fileId);
}
return map;
}, [report]);
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]); const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
const filteredStats = React.useMemo(() => computeStats(report?.json().files || [], filter), [report, filter]); const filteredStats = React.useMemo(() => filter.empty() ? undefined : computeStats(report?.json().files || [], filter), [report, filter]);
const filteredTests = React.useMemo(() => {
const result: TestModelSummary = { files: [], tests: [] };
for (const file of report?.json().files || []) {
const tests = file.tests.filter(t => filter.matches(t));
if (tests.length)
result.files.push({ ...file, tests });
result.tests.push(...tests);
}
return result;
}, [report, filter]);
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} />} {report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
<Route predicate={testFilesRoutePredicate}> <Route predicate={testFilesRoutePredicate}>
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} />
<TestFilesView <TestFilesView
report={report?.json()} tests={filteredTests.files}
filter={filter}
expandedFiles={expandedFiles} expandedFiles={expandedFiles}
setExpandedFiles={setExpandedFiles} setExpandedFiles={setExpandedFiles}
projectNames={report?.json().projectNames || []} projectNames={report?.json().projectNames || []}
filteredStats={filteredStats}
/> />
</Route> </Route>
<Route predicate={testCaseRoutePredicate}> <Route predicate={testCaseRoutePredicate}>
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>} {!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
</Route> </Route>
</main> </main>
</div>; </div>;
@ -72,21 +95,21 @@ export const ReportView: React.FC<{
const TestCaseViewLoader: React.FC<{ const TestCaseViewLoader: React.FC<{
report: LoadedReport, report: LoadedReport,
}> = ({ report }) => { tests: TestCaseSummary[],
const searchParams = new URLSearchParams(window.location.hash.slice(1)); testIdToFileIdMap: Map<string, string>,
}> = ({ report, testIdToFileIdMap, tests }) => {
const searchParams = React.useContext(SearchParamsContext);
const [test, setTest] = React.useState<TestCase | undefined>(); const [test, setTest] = React.useState<TestCase | undefined>();
const testId = searchParams.get('testId'); const testId = searchParams.get('testId');
const anchor = (searchParams.get('anchor') || '') as 'video' | 'diff' | ''; const anchor = (searchParams.get('anchor') || '') as 'video' | 'diff' | '';
const run = +(searchParams.get('run') || '0'); const run = +(searchParams.get('run') || '0');
const testIdToFileIdMap = React.useMemo(() => { const { prev, next } = React.useMemo(() => {
const map = new Map<string, string>(); const index = tests.findIndex(t => t.testId === testId);
for (const file of report.json().files) { const prev = index > 0 ? tests[index - 1] : undefined;
for (const test of file.tests) const next = index < tests.length - 1 ? tests[index + 1] : undefined;
map.set(test.testId, file.fileId); return { prev, next };
} }, [testId, tests]);
return map;
}, [report]);
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
@ -104,7 +127,15 @@ const TestCaseViewLoader: React.FC<{
} }
})(); })();
}, [test, report, testId, testIdToFileIdMap]); }, [test, report, testId, testIdToFileIdMap]);
return <TestCaseView projectNames={report.json().projectNames} test={test} anchor={anchor} run={run}></TestCaseView>;
return <TestCaseView
projectNames={report.json().projectNames}
next={next}
prev={prev}
test={test}
anchor={anchor}
run={run}
/>;
}; };
function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats { function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
@ -119,4 +150,4 @@ function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
stats.duration += test.duration; stats.duration += test.duration;
} }
return stats; return stats;
} }

View file

@ -16,7 +16,7 @@
.test-case-column { .test-case-column {
border-radius: 6px; border-radius: 6px;
margin: 24px 0; margin: 12px 0 24px 0;
} }
.test-case-column .tab-element.selected { .test-case-column .tab-element.selected {

View file

@ -16,7 +16,7 @@
import { test, expect } from '@playwright/experimental-ct-react'; import { test, expect } from '@playwright/experimental-ct-react';
import { TestCaseView } from './testCaseView'; import { TestCaseView } from './testCaseView';
import type { TestCase, TestResult } from './types'; import type { TestCase, TestCaseSummary, TestResult } from './types';
test.use({ viewport: { width: 800, height: 600 } }); test.use({ viewport: { width: 800, height: 600 } });
@ -63,7 +63,7 @@ const testCase: TestCase = {
}; };
test('should render test case', async ({ mount }) => { test('should render test case', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} run={0} anchor=''></TestCaseView>); const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible(); await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
await expect(component.getByText('Hidden annotation')).toBeHidden(); await expect(component.getByText('Hidden annotation')).toBeHidden();
await component.getByText('Annotations').click(); await component.getByText('Annotations').click();
@ -79,7 +79,7 @@ test('should render test case', async ({ mount }) => {
test('should render copy buttons for annotations', async ({ mount, page, context }) => { test('should render copy buttons for annotations', async ({ mount, page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']); await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} run={0} anchor=''></TestCaseView>); const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible(); await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
await component.getByText('Annotation text', { exact: false }).first().hover(); await component.getByText('Annotation text', { exact: false }).first().hover();
await expect(component.locator('.test-case-annotation').getByLabel('Copy to clipboard').first()).toBeVisible(); await expect(component.locator('.test-case-annotation').getByLabel('Copy to clipboard').first()).toBeVisible();
@ -108,7 +108,7 @@ const annotationLinkRenderingTestCase: TestCase = {
}; };
test('should correctly render links in annotations', async ({ mount }) => { test('should correctly render links in annotations', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={annotationLinkRenderingTestCase} run={0} anchor=''></TestCaseView>); const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={annotationLinkRenderingTestCase} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
const firstLink = await component.getByText('https://playwright.dev/docs/intro').first(); const firstLink = await component.getByText('https://playwright.dev/docs/intro').first();
await expect(firstLink).toBeVisible(); await expect(firstLink).toBeVisible();
@ -165,18 +165,49 @@ const attachmentLinkRenderingTestCase: TestCase = {
results: [resultWithAttachment] results: [resultWithAttachment]
}; };
const testCaseSummary: TestCaseSummary = {
testId: 'nextTestId',
title: 'next test',
path: [],
projectName: 'chromium',
location: { file: 'test.spec.ts', line: 42, column: 0 },
tags: [],
outcome: 'expected',
duration: 10,
ok: true,
annotations: [],
results: [resultWithAttachment]
};
test('should correctly render links in attachments', async ({ mount }) => { test('should correctly render links in attachments', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} run={0} anchor=''></TestCaseView>); const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
await component.getByText('first attachment').click(); await component.getByText('first attachment').click();
const body = await component.getByText('The body with https://playwright.dev/docs/intro link'); const body = await component.getByText('The body with https://playwright.dev/docs/intro link');
await expect(body).toBeVisible(); await expect(body).toBeVisible();
await expect(body.locator('a').filter({ hasText: 'playwright.dev' })).toHaveAttribute('href', 'https://playwright.dev/docs/intro'); await expect(body.locator('a').filter({ hasText: 'playwright.dev' })).toHaveAttribute('href', 'https://playwright.dev/docs/intro');
await expect(body.locator('a').filter({ hasText: 'github.com' })).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284'); await expect(body.locator('a').filter({ hasText: 'github.com' })).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284');
await expect(component).toMatchAriaSnapshot(`
- link "https://playwright.dev/docs/intro"
- link "https://github.com/microsoft/playwright/issues/31284"
`);
}); });
test('should correctly render links in attachment name', async ({ mount }) => { test('should correctly render links in attachment name', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} run={0} anchor=''></TestCaseView>); const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
const link = component.getByText('attachment with inline link').locator('a'); const link = component.getByText('attachment with inline link').locator('a');
await expect(link).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284'); await expect(link).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284');
await expect(link).toHaveText('https://github.com/microsoft/playwright/issues/31284'); await expect(link).toHaveText('https://github.com/microsoft/playwright/issues/31284');
await expect(component).toMatchAriaSnapshot(`
- link /https:\\/\\/github\\.com\\/microsoft\\/playwright\\/issues\\/\\d+/
`);
});
test('should correctly render prev and next', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} prev={testCaseSummary} next={testCaseSummary} run={0} anchor=''></TestCaseView>);
await expect(component).toMatchAriaSnapshot(`
- link "« previous"
- link "next »"
- text: "My test test.spec.ts:42 10ms"
`);
}); });

View file

@ -14,12 +14,12 @@
limitations under the License. limitations under the License.
*/ */
import type { TestCase, TestCaseAnnotation } from './types'; import type { TestCase, TestCaseAnnotation, TestCaseSummary } from './types';
import * as React from 'react'; import * as React from 'react';
import { TabbedPane } from './tabbedPane'; import { TabbedPane } from './tabbedPane';
import { AutoChip } from './chip'; import { AutoChip } from './chip';
import './common.css'; import './common.css';
import { ProjectLink } from './links'; import { Link, ProjectLink, SearchParamsContext } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import './testCaseView.css'; import './testCaseView.css';
import { TestResultView } from './testResultView'; import { TestResultView } from './testResultView';
@ -31,10 +31,14 @@ import { CopyToClipboardContainer } from './copyToClipboard';
export const TestCaseView: React.FC<{ export const TestCaseView: React.FC<{
projectNames: string[], projectNames: string[],
test: TestCase | undefined, test: TestCase | undefined,
next: TestCaseSummary | undefined,
prev: TestCaseSummary | undefined,
anchor: 'video' | 'diff' | '', anchor: 'video' | 'diff' | '',
run: number, run: number,
}> = ({ projectNames, test, run, anchor }) => { }> = ({ projectNames, test, run, anchor, next, prev }) => {
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run); const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
const searchParams = React.useContext(SearchParamsContext);
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
const labels = React.useMemo(() => { const labels = React.useMemo(() => {
if (!test) if (!test)
@ -47,6 +51,11 @@ export const TestCaseView: React.FC<{
}, [test?.annotations]); }, [test?.annotations]);
return <div className='test-case-column vbox'> return <div className='test-case-column vbox'>
<div className='hbox'>
{prev && <Link href={`#?testId=${prev.testId}${filterParam}`}>« previous</Link>}
<div style={{ flex: 'auto' }}></div>
{next && <Link href={`#?testId=${next.testId}${filterParam}`}>next »</Link>}
</div>
{test && <div className='test-case-path'>{test.path.join(' ')}</div>} {test && <div className='test-case-path'>{test.path.join(' ')}</div>}
{test && <div className='test-case-title'>{test?.title}</div>} {test && <div className='test-case-title'>{test?.title}</div>}
{test && <div className='hbox'> {test && <div className='hbox'>

View file

@ -14,6 +14,8 @@
limitations under the License. limitations under the License.
*/ */
@import '@web/third_party/vscode/colors.css';
.test-error-view { .test-error-view {
white-space: pre; white-space: pre;
overflow: auto; overflow: auto;

View file

@ -14,7 +14,7 @@
limitations under the License. limitations under the License.
*/ */
import ansi2html from 'ansi-to-html'; import { ansi2html } from '@web/ansi2html';
import * as React from 'react'; import * as React from 'react';
import './testErrorView.css'; import './testErrorView.css';
import type { ImageDiff } from '@web/shared/imageDiffView'; import type { ImageDiff } from '@web/shared/imageDiffView';
@ -43,33 +43,9 @@ export const TestScreenshotErrorView: React.FC<{
}; };
function ansiErrorToHtml(text?: string): string { function ansiErrorToHtml(text?: string): string {
const config: any = { const defaultColors = {
bg: 'var(--color-canvas-subtle)', bg: 'var(--color-canvas-subtle)',
fg: 'var(--color-fg-default)', fg: 'var(--color-fg-default)',
}; };
config.colors = ansiColors; return ansi2html(text || '', defaultColors);
return new ansi2html(config).toHtml(escapeHTML(text || ''));
}
const ansiColors = {
0: '#000',
1: '#C00',
2: '#0C0',
3: '#C50',
4: '#00C',
5: '#C0C',
6: '#0CC',
7: '#CCC',
8: '#555',
9: '#F55',
10: '#5F5',
11: '#FF5',
12: '#55F',
13: '#F5F',
14: '#5FF',
15: '#FFF'
};
function escapeHTML(text: string): string {
return text.replace(/[&"<>]/g, c => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
} }

View file

@ -14,24 +14,25 @@
limitations under the License. limitations under the License.
*/ */
import type { HTMLReport, TestCaseSummary, TestFileSummary } from './types'; import type { TestCaseSummary, TestFileSummary } from './types';
import * as React from 'react'; import * as React from 'react';
import { hashStringToInt, msToString } from './utils'; import { hashStringToInt, msToString } from './utils';
import { Chip } from './chip'; import { Chip } from './chip';
import { filterWithToken, type Filter } from './filter'; import { filterWithToken } from './filter';
import { generateTraceUrl, Link, navigate, ProjectLink } from './links'; import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import './testFileView.css'; import './testFileView.css';
import { video, image, trace } from './icons'; import { video, image, trace } from './icons';
import { clsx } from '@web/uiUtils'; import { clsx } from '@web/uiUtils';
export const TestFileView: React.FC<React.PropsWithChildren<{ export const TestFileView: React.FC<React.PropsWithChildren<{
report: HTMLReport;
file: TestFileSummary; file: TestFileSummary;
projectNames: string[];
isFileExpanded: (fileId: string) => boolean; isFileExpanded: (fileId: string) => boolean;
setFileExpanded: (fileId: string, expanded: boolean) => void; setFileExpanded: (fileId: string, expanded: boolean) => void;
filter: Filter; }>> = ({ file, projectNames, isFileExpanded, setFileExpanded }) => {
}>> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => { const searchParams = React.useContext(SearchParamsContext);
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
return <Chip return <Chip
expanded={isFileExpanded(file.fileId)} expanded={isFileExpanded(file.fileId)}
noInsets={true} noInsets={true}
@ -39,7 +40,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
header={<span> header={<span>
{file.fileName} {file.fileName}
</span>}> </span>}>
{file.tests.filter(t => filter.matches(t)).map(test => {file.tests.map(test =>
<div key={`test-${test.testId}`} className={clsx('test-file-test', 'test-file-test-outcome-' + test.outcome)}> <div key={`test-${test.testId}`} className={clsx('test-file-test', 'test-file-test-outcome-' + test.outcome)}>
<div className='hbox' style={{ alignItems: 'flex-start' }}> <div className='hbox' style={{ alignItems: 'flex-start' }}>
<div className='hbox'> <div className='hbox'>
@ -47,11 +48,11 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
{statusIcon(test.outcome)} {statusIcon(test.outcome)}
</span> </span>
<span> <span>
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' ')}> <Link href={`#?testId=${test.testId}${filterParam}`} title={[...test.path, test.title].join(' ')}>
<span className='test-file-title'>{[...test.path, test.title].join(' ')}</span> <span className='test-file-title'>{[...test.path, test.title].join(' ')}</span>
</Link> </Link>
{report.projectNames.length > 1 && !!test.projectName && {projectNames.length > 1 && !!test.projectName &&
<ProjectLink projectNames={report.projectNames} projectName={test.projectName} />} <ProjectLink projectNames={projectNames} projectName={test.projectName} />}
<LabelsClickView labels={test.tags} /> <LabelsClickView labels={test.tags} />
</span> </span>
</div> </div>
@ -90,10 +91,10 @@ function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
const LabelsClickView: React.FC<React.PropsWithChildren<{ const LabelsClickView: React.FC<React.PropsWithChildren<{
labels: string[], labels: string[],
}>> = ({ labels }) => { }>> = ({ labels }) => {
const searchParams = React.useContext(SearchParamsContext);
const onClickHandle = (e: React.MouseEvent, label: string) => { const onClickHandle = (e: React.MouseEvent, label: string) => {
e.preventDefault(); e.preventDefault();
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const q = searchParams.get('q')?.toString() || ''; const q = searchParams.get('q')?.toString() || '';
const tokens = q.split(' '); const tokens = q.split(' ');
navigate(filterWithToken(tokens, label, e.metaKey || e.ctrlKey)); navigate(filterWithToken(tokens, label, e.metaKey || e.ctrlKey));

View file

@ -16,7 +16,6 @@
import type { FilteredStats, HTMLReport, TestFileSummary } from './types'; import type { FilteredStats, HTMLReport, TestFileSummary } from './types';
import * as React from 'react'; import * as React from 'react';
import type { Filter } from './filter';
import { TestFileView } from './testFileView'; import { TestFileView } from './testFileView';
import './testFileView.css'; import './testFileView.css';
import { msToString } from './utils'; import { msToString } from './utils';
@ -24,40 +23,26 @@ import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView'; import { TestErrorView } from './testErrorView';
export const TestFilesView: React.FC<{ export const TestFilesView: React.FC<{
report?: HTMLReport, tests: TestFileSummary[],
expandedFiles: Map<string, boolean>, expandedFiles: Map<string, boolean>,
setExpandedFiles: (value: Map<string, boolean>) => void, setExpandedFiles: (value: Map<string, boolean>) => void,
filter: Filter,
filteredStats: FilteredStats,
projectNames: string[], projectNames: string[],
}> = ({ report, filter, expandedFiles, setExpandedFiles, projectNames, filteredStats }) => { }> = ({ tests, expandedFiles, setExpandedFiles, projectNames }) => {
const filteredFiles = React.useMemo(() => { const filteredFiles = React.useMemo(() => {
const result: { file: TestFileSummary, defaultExpanded: boolean }[] = []; const result: { file: TestFileSummary, defaultExpanded: boolean }[] = [];
let visibleTests = 0; let visibleTests = 0;
for (const file of report?.files || []) { for (const file of tests) {
const tests = file.tests.filter(t => filter.matches(t)); visibleTests += file.tests.length;
visibleTests += tests.length; result.push({ file, defaultExpanded: visibleTests < 200 });
if (tests.length)
result.push({ file, defaultExpanded: visibleTests < 200 });
} }
return result; return result;
}, [report, filter]); }, [tests]);
return <> return <>
<div className='mt-2 mx-1' style={{ display: 'flex' }}> {filteredFiles.map(({ file, defaultExpanded }) => {
{projectNames.length === 1 && !!projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {projectNames[0]}</div>}
{!filter.empty() && <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 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>
{report && !!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>}
{report && filteredFiles.map(({ file, defaultExpanded }) => {
return <TestFileView return <TestFileView
key={`file-${file.fileId}`} key={`file-${file.fileId}`}
report={report}
file={file} file={file}
projectNames={projectNames}
isFileExpanded={fileId => { isFileExpanded={fileId => {
const value = expandedFiles.get(fileId); const value = expandedFiles.get(fileId);
if (value === undefined) if (value === undefined)
@ -68,9 +53,28 @@ export const TestFilesView: React.FC<{
const newExpanded = new Map(expandedFiles); const newExpanded = new Map(expandedFiles);
newExpanded.set(fileId, expanded); newExpanded.set(fileId, expanded);
setExpandedFiles(newExpanded); setExpandedFiles(newExpanded);
}} }}>
filter={filter}>
</TestFileView>; </TestFileView>;
})} })}
</>; </>;
}; };
export const TestFilesHeader: React.FC<{
report: HTMLReport | undefined,
filteredStats?: FilteredStats,
}> = ({ report, filteredStats }) => {
if (!report)
return;
return <>
<div className='mt-2 mx-1' style={{ display: 'flex' }}>
{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>}
<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-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
</div>
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>}
</>;
};

View file

@ -152,7 +152,8 @@ export const TestResultView: React.FC<{
function classifyErrors(testErrors: string[], diffs: ImageDiff[]) { function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
return testErrors.map(error => { return testErrors.map(error => {
if (error.includes('Screenshot comparison failed:')) { const firstLine = error.split('\n')[0];
if (firstLine.includes('toHaveScreenshot') || firstLine.includes('toMatchSnapshot')) {
const matchingDiff = diffs.find(diff => { const matchingDiff = diffs.find(diff => {
const attachmentName = diff.actual?.attachment.name; const attachmentName = diff.actual?.attachment.name;
return attachmentName && error.includes(attachmentName); return attachmentName && error.includes(attachmentName);

View file

@ -3,9 +3,9 @@
"browsers": [ "browsers": [
{ {
"name": "chromium", "name": "chromium",
"revision": "1143", "revision": "1146",
"installByDefault": true, "installByDefault": true,
"browserVersion": "131.0.6778.3" "browserVersion": "131.0.6778.24"
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
@ -27,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2095", "revision": "2102",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"mac10.14": "1446", "mac10.14": "1446",

View file

@ -554,6 +554,7 @@ async function open(options: Options, url: string | undefined, language: string)
contextOptions, contextOptions,
device: options.device, device: options.device,
saveStorage: options.saveStorage, saveStorage: options.saveStorage,
handleSIGINT: false,
}); });
await openPage(context, url); await openPage(context, url);
} }
@ -577,6 +578,7 @@ async function codegen(options: Options & { target: string, output?: string, tes
codegenMode: process.env.PW_RECORDER_IS_TRACE_VIEWER ? 'trace-events' : 'actions', codegenMode: process.env.PW_RECORDER_IS_TRACE_VIEWER ? 'trace-events' : 'actions',
testIdAttributeName, testIdAttributeName,
outputFile: outputFile ? path.resolve(outputFile) : undefined, outputFile: outputFile ? path.resolve(outputFile) : undefined,
handleSIGINT: false,
}); });
await openPage(context, url); await openPage(context, url);
} }

View file

@ -21,7 +21,7 @@ import * as util from 'util';
import { asLocator, isString, monotonicTime } from '../utils'; import { asLocator, isString, monotonicTime } from '../utils';
import { ElementHandle } from './elementHandle'; import { ElementHandle } from './elementHandle';
import type { Frame } from './frame'; import type { Frame } from './frame';
import type { FilePayload, FrameExpectOptions, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; import type { FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
import { parseResult, serializeArgument } from './jsHandle'; import { parseResult, serializeArgument } from './jsHandle';
import { escapeForTextSelector } from '../utils/isomorphic/stringUtils'; import { escapeForTextSelector } from '../utils/isomorphic/stringUtils';
import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils';
@ -354,7 +354,7 @@ export class Locator implements api.Locator {
await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options }); await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options });
} }
async _expect(expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { async _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot };
params.expectedValue = serializeArgument(options.expectedValue); params.expectedValue = serializeArgument(options.expectedValue);
const result = (await this._frame._channel.expect(params)); const result = (await this._frame._channel.expect(params));

View file

@ -63,6 +63,7 @@ type PDFOptions = Omit<channels.PagePdfParams, 'width' | 'height' | 'margin'> &
export type ExpectScreenshotOptions = Omit<channels.PageExpectScreenshotOptions, 'locator' | 'expected' | 'mask'> & { export type ExpectScreenshotOptions = Omit<channels.PageExpectScreenshotOptions, 'locator' | 'expected' | 'mask'> & {
expected?: Buffer, expected?: Buffer,
locator?: api.Locator, locator?: api.Locator,
timeout: number,
isNot: boolean, isNot: boolean,
mask?: api.Locator[], mask?: api.Locator[],
}; };
@ -589,7 +590,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return result.binary; return result.binary;
} }
async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[]}> { async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[], timedOut?: boolean}> {
const mask = options?.mask ? options?.mask.map(locator => ({ const mask = options?.mask ? options?.mask.map(locator => ({
frame: (locator as Locator)._frame._channel, frame: (locator as Locator)._frame._channel,
selector: (locator as Locator)._selector, selector: (locator as Locator)._selector,

View file

@ -154,4 +154,4 @@ export type SelectorEngine = {
export type RemoteAddr = channels.RemoteAddr; export type RemoteAddr = channels.RemoteAddr;
export type SecurityDetails = channels.SecurityDetails; export type SecurityDetails = channels.SecurityDetails;
export type FrameExpectOptions = channels.FrameExpectOptions & { isNot?: boolean }; export type FrameExpectParams = Omit<channels.FrameExpectParams, 'selector'|'expression'|'expectedValue'> & { expectedValue?: any };

View file

@ -976,6 +976,7 @@ scheme.BrowserContextEnableRecorderParams = tObject({
device: tOptional(tString), device: tOptional(tString),
saveStorage: tOptional(tString), saveStorage: tOptional(tString),
outputFile: tOptional(tString), outputFile: tOptional(tString),
handleSIGINT: tOptional(tBoolean),
omitCallTracking: tOptional(tBoolean), omitCallTracking: tOptional(tBoolean),
}); });
scheme.BrowserContextEnableRecorderResult = tOptional(tObject({})); scheme.BrowserContextEnableRecorderResult = tOptional(tObject({}));
@ -1164,7 +1165,7 @@ scheme.PageReloadResult = tObject({
}); });
scheme.PageExpectScreenshotParams = tObject({ scheme.PageExpectScreenshotParams = tObject({
expected: tOptional(tBinary), expected: tOptional(tBinary),
timeout: tOptional(tNumber), timeout: tNumber,
isNot: tBoolean, isNot: tBoolean,
locator: tOptional(tObject({ locator: tOptional(tObject({
frame: tChannel(['Frame']), frame: tChannel(['Frame']),
@ -1192,6 +1193,7 @@ scheme.PageExpectScreenshotResult = tObject({
errorMessage: tOptional(tString), errorMessage: tOptional(tString),
actual: tOptional(tBinary), actual: tOptional(tBinary),
previous: tOptional(tBinary), previous: tOptional(tBinary),
timedOut: tOptional(tBoolean),
log: tOptional(tArray(tString)), log: tOptional(tArray(tString)),
}); });
scheme.PageScreenshotParams = tObject({ scheme.PageScreenshotParams = tObject({
@ -1768,7 +1770,7 @@ scheme.FrameExpectParams = tObject({
expectedValue: tOptional(tType('SerializedArgument')), expectedValue: tOptional(tType('SerializedArgument')),
useInnerText: tOptional(tBoolean), useInnerText: tOptional(tBoolean),
isNot: tBoolean, isNot: tBoolean,
timeout: tOptional(tNumber), timeout: tNumber,
}); });
scheme.FrameExpectResult = tObject({ scheme.FrameExpectResult = tObject({
matches: tBoolean, matches: tBoolean,

View file

@ -14,39 +14,54 @@
* limitations under the License. * limitations under the License.
*/ */
import type { AriaTemplateNode } from './injected/ariaSnapshot'; import type { AriaTemplateNode, AriaTemplateRoleNode } from './injected/ariaSnapshot';
import { yaml } from '../utilsBundle'; import { yaml } from '../utilsBundle';
import type { AriaRole } from '@injected/roleUtils';
import { assert } from '../utils'; import { assert } from '../utils';
export function parseAriaSnapshot(text: string): AriaTemplateNode { export function parseAriaSnapshot(text: string): AriaTemplateNode {
const fragment = yaml.parse(text) as any[]; const fragment = yaml.parse(text) as any[];
const result: AriaTemplateNode = { role: 'fragment' }; const result: AriaTemplateNode = { kind: 'role', role: 'fragment' };
populateNode(result, fragment); populateNode(result, fragment);
return result; return result;
} }
function populateNode(node: AriaTemplateNode, container: any[]) { function populateNode(node: AriaTemplateRoleNode, container: any[]) {
for (const object of container) { for (const object of container) {
if (typeof object === 'string') { if (typeof object === 'string') {
const childNode = parseKey(object); const childNode = KeyParser.parse(object);
node.children = node.children || []; node.children = node.children || [];
node.children.push(childNode); node.children.push(childNode);
continue; continue;
} }
for (const key of Object.keys(object)) { for (const key of Object.keys(object)) {
const childNode = parseKey(key);
const value = object[key];
node.children = node.children || []; node.children = node.children || [];
const value = object[key];
if (childNode.role === 'text') { if (key === 'text') {
node.children.push(valueOrRegex(value)); node.children.push({
kind: 'text',
text: valueOrRegex(value)
});
continue;
}
const childNode = KeyParser.parse(key);
if (childNode.kind === 'text') {
node.children.push({
kind: 'text',
text: valueOrRegex(value)
});
continue; continue;
} }
if (typeof value === 'string') { if (typeof value === 'string') {
node.children.push({ ...childNode, children: [valueOrRegex(value)] }); node.children.push({
...childNode, children: [{
kind: 'text',
text: valueOrRegex(value)
}]
});
continue; continue;
} }
@ -56,7 +71,7 @@ function populateNode(node: AriaTemplateNode, container: any[]) {
} }
} }
function applyAttribute(node: AriaTemplateNode, key: string, value: string) { function applyAttribute(node: AriaTemplateRoleNode, key: string, value: string) {
if (key === 'checked') { if (key === 'checked') {
assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "disabled" attribute must be a boolean or "mixed"'); assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "disabled" attribute must be a boolean or "mixed"');
node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed'; node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed';
@ -90,47 +105,6 @@ function applyAttribute(node: AriaTemplateNode, key: string, value: string) {
throw new Error(`Unsupported attribute [${key}] `); throw new Error(`Unsupported attribute [${key}] `);
} }
function parseKey(key: string): AriaTemplateNode {
const tokenRegex = /\s*([a-z]+|"(?:[^"]*)"|\/(?:[^\/]*)\/|\[.*?\])/g;
let match;
const tokens = [];
while ((match = tokenRegex.exec(key)) !== null)
tokens.push(match[1]);
if (tokens.length === 0)
throw new Error(`Invalid key ${key}`);
const role = tokens[0] as AriaRole | 'text';
let name: string | RegExp = '';
let index = 1;
if (tokens.length > 1 && (tokens[1].startsWith('"') || tokens[1].startsWith('/'))) {
const nameToken = tokens[1];
if (nameToken.startsWith('"')) {
name = nameToken.slice(1, -1);
} else {
const pattern = nameToken.slice(1, -1);
name = new RegExp(pattern);
}
index = 2;
}
const result: AriaTemplateNode = { role, name };
for (; index < tokens.length; index++) {
const attrToken = tokens[index];
if (attrToken.startsWith('[') && attrToken.endsWith(']')) {
const attrContent = attrToken.slice(1, -1).trim();
const [attrName, attrValue] = attrContent.split('=', 2);
const value = attrValue !== undefined ? attrValue.trim() : 'true';
applyAttribute(result, attrName, value);
} else {
throw new Error(`Invalid attribute token ${attrToken} in key ${key}`);
}
}
return result;
}
function normalizeWhitespace(text: string) { function normalizeWhitespace(text: string) {
return text.replace(/[\r\n\s\t]+/g, ' ').trim(); return text.replace(/[\r\n\s\t]+/g, ' ').trim();
} }
@ -138,3 +112,148 @@ function normalizeWhitespace(text: string) {
function valueOrRegex(value: string): string | RegExp { function valueOrRegex(value: string): string | RegExp {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value); return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
} }
export class KeyParser {
private _input: string;
private _pos: number;
private _length: number;
static parse(input: string): AriaTemplateNode {
return new KeyParser(input)._parse();
}
constructor(input: string) {
this._input = input;
this._pos = 0;
this._length = input.length;
}
private _peek() {
return this._input[this._pos] || '';
}
private _next() {
if (this._pos < this._length)
return this._input[this._pos++];
return null;
}
private _eof() {
return this._pos >= this._length;
}
private _skipWhitespace() {
while (!this._eof() && /\s/.test(this._peek()))
this._pos++;
}
private _readIdentifier(): string {
if (this._eof())
throw new Error('Unexpected end of input when expecting identifier');
const start = this._pos;
while (!this._eof() && /[a-zA-Z]/.test(this._peek()))
this._pos++;
return this._input.slice(start, this._pos);
}
private _readString(): string {
let result = '';
let escaped = false;
while (!this._eof()) {
const ch = this._next();
if (escaped) {
result += ch;
escaped = false;
} else if (ch === '\\') {
escaped = true;
result += ch;
} else if (ch === '"') {
return result;
} else {
result += ch;
}
}
throw new Error('Unterminated string starting at position ' + this._pos);
}
private _readRegex(): string {
let result = '';
let escaped = false;
while (!this._eof()) {
const ch = this._next();
if (escaped) {
result += ch;
escaped = false;
} else if (ch === '\\') {
escaped = true;
result += ch;
} else if (ch === '/') {
return result;
} else {
result += ch;
}
}
throw new Error('Unterminated regex starting at position ' + this._pos);
}
private _readStringOrRegex(): string | RegExp | null {
const ch = this._peek();
if (ch === '"') {
this._next();
return this._readString();
}
if (ch === '/') {
this._next();
return new RegExp(this._readRegex());
}
return null;
}
private _readFlags(): Map<string, string> {
const flags = new Map<string, string>();
while (true) {
this._skipWhitespace();
if (this._peek() === '[') {
this._next();
this._skipWhitespace();
const flagName = this._readIdentifier();
this._skipWhitespace();
let flagValue = '';
if (this._peek() === '=') {
this._next();
this._skipWhitespace();
while (this._peek() !== ']' && !this._eof())
flagValue += this._next();
}
this._skipWhitespace();
if (this._peek() !== ']')
throw new Error('Expected ] at position ' + this._pos);
this._next(); // Consume ']'
flags.set(flagName, flagValue || 'true');
} else {
break;
}
}
return flags;
}
_parse(): AriaTemplateNode {
this._skipWhitespace();
const role = this._readIdentifier() as AriaTemplateRoleNode['role'];
this._skipWhitespace();
const name = this._readStringOrRegex() || '';
const result: AriaTemplateRoleNode = { kind: 'role', role, name };
const flags = this._readFlags();
for (const [name, value] of flags)
applyAttribute(result, name, value);
this._skipWhitespace();
if (!this._eof())
throw new Error('Unexpected input at position ' + this._pos);
return result;
}
}

View file

@ -294,6 +294,8 @@ export class Chromium extends BrowserType {
throw new Error('Playwright manages remote debugging connection itself.'); throw new Error('Playwright manages remote debugging connection itself.');
if (args.find(arg => !arg.startsWith('-'))) if (args.find(arg => !arg.startsWith('-')))
throw new Error('Arguments can not specify page to be opened'); throw new Error('Arguments can not specify page to be opened');
if (!options.headless && options.channel === 'chromium-headless-shell')
throw new Error('Cannot launch headed Chromium with `chromium-headless-shell` channel. Consider using regular Chromium instead.');
const chromeArguments = [...chromiumSwitches]; const chromeArguments = [...chromiumSwitches];
if (os.platform() === 'darwin') { if (os.platform() === 'darwin') {

View file

@ -275,10 +275,13 @@ ${body}
} }
export function quoteMultiline(text: string, indent = ' ') { export function quoteMultiline(text: string, indent = ' ') {
const escape = (text: string) => text.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${');
const lines = text.split('\n'); const lines = text.split('\n');
if (lines.length === 1) if (lines.length === 1)
return '`' + text.replace(/`/g, '\\`').replace(/\${/g, '\\${') + '`'; return '`' + escape(text) + '`';
return '`\n' + lines.map(line => indent + line.replace(/`/g, '\\`').replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``; return '`\n' + lines.map(line => indent + escape(line).replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
} }
function isMultilineString(text: string) { function isMultilineString(text: string) {

View file

@ -110,7 +110,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Galaxy S5": { "Galaxy S5": {
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -121,7 +121,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S5 landscape": { "Galaxy S5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -132,7 +132,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S8": { "Galaxy S8": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 740 "height": 740
@ -143,7 +143,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S8 landscape": { "Galaxy S8 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 740, "width": 740,
"height": 360 "height": 360
@ -154,7 +154,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S9+": { "Galaxy S9+": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 320, "width": 320,
"height": 658 "height": 658
@ -165,7 +165,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S9+ landscape": { "Galaxy S9+ landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 658, "width": 658,
"height": 320 "height": 320
@ -176,7 +176,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy Tab S4": { "Galaxy Tab S4": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36",
"viewport": { "viewport": {
"width": 712, "width": 712,
"height": 1138 "height": 1138
@ -187,7 +187,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy Tab S4 landscape": { "Galaxy Tab S4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36",
"viewport": { "viewport": {
"width": 1138, "width": 1138,
"height": 712 "height": 712
@ -1098,7 +1098,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"LG Optimus L70": { "LG Optimus L70": {
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 384, "width": 384,
"height": 640 "height": 640
@ -1109,7 +1109,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"LG Optimus L70 landscape": { "LG Optimus L70 landscape": {
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 384 "height": 384
@ -1120,7 +1120,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 550": { "Microsoft Lumia 550": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36 Edge/14.14263", "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36 Edge/14.14263",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1131,7 +1131,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 550 landscape": { "Microsoft Lumia 550 landscape": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36 Edge/14.14263", "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36 Edge/14.14263",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1142,7 +1142,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 950": { "Microsoft Lumia 950": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36 Edge/14.14263", "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36 Edge/14.14263",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1153,7 +1153,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 950 landscape": { "Microsoft Lumia 950 landscape": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36 Edge/14.14263", "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36 Edge/14.14263",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1164,7 +1164,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 10": { "Nexus 10": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36",
"viewport": { "viewport": {
"width": 800, "width": 800,
"height": 1280 "height": 1280
@ -1175,7 +1175,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 10 landscape": { "Nexus 10 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36",
"viewport": { "viewport": {
"width": 1280, "width": 1280,
"height": 800 "height": 800
@ -1186,7 +1186,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 4": { "Nexus 4": {
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 384, "width": 384,
"height": 640 "height": 640
@ -1197,7 +1197,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 4 landscape": { "Nexus 4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 384 "height": 384
@ -1208,7 +1208,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5": { "Nexus 5": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1219,7 +1219,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5 landscape": { "Nexus 5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1230,7 +1230,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5X": { "Nexus 5X": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1241,7 +1241,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5X landscape": { "Nexus 5X landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1252,7 +1252,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6": { "Nexus 6": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1263,7 +1263,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6 landscape": { "Nexus 6 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1274,7 +1274,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6P": { "Nexus 6P": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1285,7 +1285,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6P landscape": { "Nexus 6P landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1296,7 +1296,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 7": { "Nexus 7": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36",
"viewport": { "viewport": {
"width": 600, "width": 600,
"height": 960 "height": 960
@ -1307,7 +1307,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 7 landscape": { "Nexus 7 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36",
"viewport": { "viewport": {
"width": 960, "width": 960,
"height": 600 "height": 600
@ -1362,7 +1362,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Pixel 2": { "Pixel 2": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 411, "width": 411,
"height": 731 "height": 731
@ -1373,7 +1373,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 landscape": { "Pixel 2 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 731, "width": 731,
"height": 411 "height": 411
@ -1384,7 +1384,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 XL": { "Pixel 2 XL": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 411, "width": 411,
"height": 823 "height": 823
@ -1395,7 +1395,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 XL landscape": { "Pixel 2 XL landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 823, "width": 823,
"height": 411 "height": 411
@ -1406,7 +1406,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 3": { "Pixel 3": {
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 393, "width": 393,
"height": 786 "height": 786
@ -1417,7 +1417,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 3 landscape": { "Pixel 3 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 786, "width": 786,
"height": 393 "height": 393
@ -1428,7 +1428,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4": { "Pixel 4": {
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 353, "width": 353,
"height": 745 "height": 745
@ -1439,7 +1439,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4 landscape": { "Pixel 4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 745, "width": 745,
"height": 353 "height": 353
@ -1450,7 +1450,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4a (5G)": { "Pixel 4a (5G)": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"screen": { "screen": {
"width": 412, "width": 412,
"height": 892 "height": 892
@ -1465,7 +1465,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4a (5G) landscape": { "Pixel 4a (5G) landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"screen": { "screen": {
"height": 892, "height": 892,
"width": 412 "width": 412
@ -1480,7 +1480,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 5": { "Pixel 5": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"screen": { "screen": {
"width": 393, "width": 393,
"height": 851 "height": 851
@ -1495,7 +1495,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 5 landscape": { "Pixel 5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"screen": { "screen": {
"width": 851, "width": 851,
"height": 393 "height": 393
@ -1510,7 +1510,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 7": { "Pixel 7": {
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"screen": { "screen": {
"width": 412, "width": 412,
"height": 915 "height": 915
@ -1525,7 +1525,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 7 landscape": { "Pixel 7 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"screen": { "screen": {
"width": 915, "width": 915,
"height": 412 "height": 412
@ -1540,7 +1540,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Moto G4": { "Moto G4": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1551,7 +1551,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Moto G4 landscape": { "Moto G4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1562,7 +1562,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Chrome HiDPI": { "Desktop Chrome HiDPI": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36",
"screen": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1577,7 +1577,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Edge HiDPI": { "Desktop Edge HiDPI": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36 Edg/131.0.6778.3", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36 Edg/131.0.6778.24",
"screen": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1622,7 +1622,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Desktop Chrome": { "Desktop Chrome": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36",
"screen": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080
@ -1637,7 +1637,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Edge": { "Desktop Edge": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36 Edg/131.0.6778.3", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.24 Safari/537.36 Edg/131.0.6778.24",
"screen": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080

View file

@ -17,7 +17,7 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { findValidator, ValidationError, createMetadataValidator, type ValidatorContext } from '../../protocol/validator'; import { findValidator, ValidationError, createMetadataValidator, type ValidatorContext } from '../../protocol/validator';
import { LongStandingScope, assert, isUnderTest, monotonicTime, rewriteErrorMessage } from '../../utils'; import { LongStandingScope, assert, compressCallLog, isUnderTest, monotonicTime, rewriteErrorMessage } from '../../utils';
import { TargetClosedError, isTargetClosedError, serializeError } from '../errors'; import { TargetClosedError, isTargetClosedError, serializeError } from '../errors';
import type { CallMetadata } from '../instrumentation'; import type { CallMetadata } from '../instrumentation';
import { SdkObject } from '../instrumentation'; import { SdkObject } from '../instrumentation';
@ -357,7 +357,7 @@ export class DispatcherConnection {
} }
if (response.error) if (response.error)
response.log = callMetadata.log; response.log = compressCallLog(callMetadata.log);
this.onmessage(response); this.onmessage(response);
} }
} }

View file

@ -299,7 +299,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
while (progress.isRunning()) { while (progress.isRunning()) {
if (retry) { if (retry) {
progress.log(`retrying ${actionName} action${options.trial ? ' (trial run)' : ''}, attempt #${retry}`); progress.log(`retrying ${actionName} action${options.trial ? ' (trial run)' : ''}`);
const timeout = waitTime[Math.min(retry - 1, waitTime.length - 1)]; const timeout = waitTime[Math.min(retry - 1, waitTime.length - 1)];
if (timeout) { if (timeout) {
progress.log(` waiting ${timeout}ms`); progress.log(` waiting ${timeout}ms`);

View file

@ -29,7 +29,7 @@ import * as types from './types';
import { BrowserContext } from './browserContext'; import { BrowserContext } from './browserContext';
import type { Progress } from './progress'; import type { Progress } from './progress';
import { ProgressController } from './progress'; import { ProgressController } from './progress';
import { LongStandingScope, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime, asLocator } from '../utils'; import { LongStandingScope, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime, asLocator, compressCallLog } from '../utils';
import { ManualPromise } from '../utils/manualPromise'; import { ManualPromise } from '../utils/manualPromise';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import type { CallMetadata } from './instrumentation'; import type { CallMetadata } from './instrumentation';
@ -1458,7 +1458,7 @@ export class Frame extends SdkObject {
timeout -= elapsed; timeout -= elapsed;
} }
if (timeout < 0) if (timeout < 0)
return { matches: options.isNot, log: metadata.log, timedOut: true, received: lastIntermediateResult.received }; return { matches: options.isNot, log: compressCallLog(metadata.log), timedOut: true, received: lastIntermediateResult.received };
// Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time. // Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time.
return await (new ProgressController(metadata, this)).run(async progress => { return await (new ProgressController(metadata, this)).run(async progress => {
@ -1479,7 +1479,7 @@ export class Frame extends SdkObject {
// A: We want user to receive a friendly message containing the last intermediate result. // A: We want user to receive a friendly message containing the last intermediate result.
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e))
throw e; throw e;
const result: { matches: boolean, received?: any, log?: string[], timedOut?: boolean } = { matches: options.isNot, log: metadata.log }; const result: { matches: boolean, received?: any, log?: string[], timedOut?: boolean } = { matches: options.isNot, log: compressCallLog(metadata.log) };
if (lastIntermediateResult.isSet) if (lastIntermediateResult.isSet)
result.received = lastIntermediateResult.received; result.received = lastIntermediateResult.received;
if (e instanceof TimeoutError) if (e instanceof TimeoutError)

View file

@ -14,10 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
import { escapeWithQuotes } from '@isomorphic/stringUtils';
import * as roleUtils from './roleUtils'; import * as roleUtils from './roleUtils';
import { getElementComputedStyle } from './domUtils'; import { getElementComputedStyle } from './domUtils';
import type { AriaRole } from './roleUtils'; import type { AriaRole } from './roleUtils';
import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils';
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded, yamlQuoteFragment } from './yaml';
type AriaProps = { type AriaProps = {
checked?: boolean | 'mixed'; checked?: boolean | 'mixed';
@ -34,14 +35,27 @@ type AriaNode = AriaProps & {
children: (AriaNode | string)[]; children: (AriaNode | string)[];
}; };
export type AriaTemplateNode = AriaProps & { export type AriaTemplateTextNode = {
role: AriaRole | 'fragment' | 'text'; kind: 'text';
name?: RegExp | string; text: RegExp | string;
children?: (AriaTemplateNode | string | RegExp)[];
}; };
export type AriaTemplateRoleNode = AriaProps & {
kind: 'role';
role: AriaRole | 'fragment';
name?: RegExp | string;
children?: AriaTemplateNode[];
};
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
export function generateAriaTree(rootElement: Element): AriaNode { export function generateAriaTree(rootElement: Element): AriaNode {
const visited = new Set<Node>();
const visit = (ariaNode: AriaNode, node: Node) => { const visit = (ariaNode: AriaNode, node: Node) => {
if (visited.has(node))
return;
visited.add(node);
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) { if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
const text = node.nodeValue; const text = node.nodeValue;
if (text) if (text)
@ -56,13 +70,23 @@ export function generateAriaTree(rootElement: Element): AriaNode {
if (roleUtils.isElementHiddenForAria(element)) if (roleUtils.isElementHiddenForAria(element))
return; return;
const ariaChildren: Element[] = [];
if (element.hasAttribute('aria-owns')) {
const ids = element.getAttribute('aria-owns')!.split(/\s+/);
for (const id of ids) {
const ownedElement = rootElement.ownerDocument.getElementById(id);
if (ownedElement)
ariaChildren.push(ownedElement);
}
}
const childAriaNode = toAriaNode(element); const childAriaNode = toAriaNode(element);
if (childAriaNode) if (childAriaNode)
ariaNode.children.push(childAriaNode); ariaNode.children.push(childAriaNode);
processChildNodes(childAriaNode || ariaNode, element); processElement(childAriaNode || ariaNode, element, ariaChildren);
}; };
function processChildNodes(ariaNode: AriaNode, element: Element) { function processElement(ariaNode: AriaNode, element: Element, ariaChildren: Element[] = []) {
// Surround every element with spaces for the sake of concatenated text nodes. // Surround every element with spaces for the sake of concatenated text nodes.
const display = getElementComputedStyle(element)?.display || 'inline'; const display = getElementComputedStyle(element)?.display || 'inline';
const treatAsBlock = (display !== 'inline' || element.nodeName === 'BR') ? ' ' : ''; const treatAsBlock = (display !== 'inline' || element.nodeName === 'BR') ? ' ' : '';
@ -85,12 +109,15 @@ export function generateAriaTree(rootElement: Element): AriaNode {
} }
} }
for (const child of ariaChildren)
visit(ariaNode, child);
ariaNode.children.push(roleUtils.getPseudoContent(element, '::after')); ariaNode.children.push(roleUtils.getPseudoContent(element, '::after'));
if (treatAsBlock) if (treatAsBlock)
ariaNode.children.push(treatAsBlock); ariaNode.children.push(treatAsBlock);
if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0]) if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])
ariaNode.children = []; ariaNode.children = [];
} }
@ -108,7 +135,7 @@ export function generateAriaTree(rootElement: Element): AriaNode {
function toAriaNode(element: Element): AriaNode | null { function toAriaNode(element: Element): AriaNode | null {
const role = roleUtils.getAriaRole(element); const role = roleUtils.getAriaRole(element);
if (!role) if (!role || role === 'presentation' || role === 'none')
return null; return null;
const name = roleUtils.getElementAccessibleName(element, false) || ''; const name = roleUtils.getElementAccessibleName(element, false) || '';
@ -132,11 +159,14 @@ function toAriaNode(element: Element): AriaNode | null {
if (roleUtils.kAriaSelectedRoles.includes(role)) if (roleUtils.kAriaSelectedRoles.includes(role))
result.selected = roleUtils.getAriaSelected(element); result.selected = roleUtils.getAriaSelected(element);
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)
result.children = [element.value];
return result; return result;
} }
export function renderedAriaTree(rootElement: Element): string { export function renderedAriaTree(rootElement: Element, options?: { mode?: 'raw' | 'regex' }): string {
return renderAriaTree(generateAriaTree(rootElement)); return renderAriaTree(generateAriaTree(rootElement), options);
} }
function normalizeStringChildren(rootA11yNode: AriaNode) { function normalizeStringChildren(rootA11yNode: AriaNode) {
@ -169,9 +199,9 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
visit(rootA11yNode); visit(rootA11yNode);
} }
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\t\r\n]+/g, ' '); const normalizeWhitespaceWithin = (text: string) => text.replace(/[\u200b\s\t\r\n]+/g, ' ');
function matchesText(text: string | undefined, template: RegExp | string | undefined) { function matchesText(text: string, template: RegExp | string | undefined): boolean {
if (!template) if (!template)
return true; return true;
if (!text) if (!text)
@ -181,17 +211,36 @@ function matchesText(text: string | undefined, template: RegExp | string | undef
return !!text.match(template); return !!text.match(template);
} }
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } { function matchesTextNode(text: string, template: AriaTemplateTextNode) {
const root = generateAriaTree(rootElement); return matchesText(text, template.text);
const matches = matchesNodeDeep(root, template);
return { matches, received: renderAriaTree(root, { noText: true }) };
} }
function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean { function matchesName(text: string, template: AriaTemplateRoleNode) {
if (typeof node === 'string' && (typeof template === 'string' || template instanceof RegExp)) return matchesText(text, template.name);
return matchesText(node, template); }
if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) { export type MatcherReceived = {
raw: string;
regex: string;
};
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: MatcherReceived } {
const root = generateAriaTree(rootElement);
const matches = matchesNodeDeep(root, template);
return {
matches,
received: {
raw: renderAriaTree(root, { mode: 'raw' }),
regex: renderAriaTree(root, { mode: 'regex' }),
}
};
}
function matchesNode(node: AriaNode | string, template: AriaTemplateNode, depth: number): boolean {
if (typeof node === 'string' && template.kind === 'text')
return matchesTextNode(node, template);
if (typeof node === 'object' && template.kind === 'role') {
if (template.role !== 'fragment' && template.role !== node.role) if (template.role !== 'fragment' && template.role !== node.role)
return false; return false;
if (template.checked !== undefined && template.checked !== node.checked) if (template.checked !== undefined && template.checked !== node.checked)
@ -206,7 +255,7 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegEx
return false; return false;
if (template.selected !== undefined && template.selected !== node.selected) if (template.selected !== undefined && template.selected !== node.selected)
return false; return false;
if (!matchesText(node.name, template.name)) if (!matchesName(node.name, template))
return false; return false;
if (!containsList(node.children || [], template.children || [], depth)) if (!containsList(node.children || [], template.children || [], depth))
return false; return false;
@ -215,7 +264,7 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegEx
return false; return false;
} }
function containsList(children: (AriaNode | string)[], template: (AriaTemplateNode | RegExp | string)[], depth: number): boolean { function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], depth: number): boolean {
if (template.length > children.length) if (template.length > children.length)
return false; return false;
const cc = children.slice(); const cc = children.slice();
@ -252,54 +301,123 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean {
return !!results.length; return !!results.length;
} }
export function renderAriaTree(ariaNode: AriaNode, options?: { noText?: boolean }): string { export function renderAriaTree(ariaNode: AriaNode, options?: { mode?: 'raw' | 'regex' }): string {
const lines: string[] = []; const lines: string[] = [];
const visit = (ariaNode: AriaNode | string, indent: string) => { const includeText = options?.mode === 'regex' ? textContributesInfo : () => true;
const renderString = options?.mode === 'regex' ? convertToBestGuessRegex : (str: string) => str;
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
if (typeof ariaNode === 'string') { if (typeof ariaNode === 'string') {
if (!options?.noText) if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
lines.push(indent + '- text: ' + quoteYamlString(ariaNode)); return;
const text = renderString(ariaNode);
if (text)
lines.push(indent + '- text: ' + text);
return; return;
} }
let line = `${indent}- ${ariaNode.role}`;
if (ariaNode.name)
line += ` ${escapeWithQuotes(ariaNode.name, '"')}`;
let key = ariaNode.role;
if (ariaNode.name) {
const name = renderString(ariaNode.name);
if (name)
key += ' ' + (name.startsWith('/') && name.endsWith('/') ? name : yamlQuoteFragment(name));
}
if (ariaNode.checked === 'mixed') if (ariaNode.checked === 'mixed')
line += ` [checked=mixed]`; key += ` [checked=mixed]`;
if (ariaNode.checked === true) if (ariaNode.checked === true)
line += ` [checked]`; key += ` [checked]`;
if (ariaNode.disabled) if (ariaNode.disabled)
line += ` [disabled]`; key += ` [disabled]`;
if (ariaNode.expanded) if (ariaNode.expanded)
line += ` [expanded]`; key += ` [expanded]`;
if (ariaNode.level) if (ariaNode.level)
line += ` [level=${ariaNode.level}]`; key += ` [level=${ariaNode.level}]`;
if (ariaNode.pressed === 'mixed') if (ariaNode.pressed === 'mixed')
line += ` [pressed=mixed]`; key += ` [pressed=mixed]`;
if (ariaNode.pressed === true) if (ariaNode.pressed === true)
line += ` [pressed]`; key += ` [pressed]`;
if (ariaNode.selected === true) if (ariaNode.selected === true)
line += ` [selected]`; key += ` [selected]`;
lines.push(line + (ariaNode.children.length ? ':' : '')); const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
for (const child of ariaNode.children || []) if (!ariaNode.children.length) {
visit(child, indent + ' '); lines.push(escapedKey);
} else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string') {
const text = includeText(ariaNode, ariaNode.children[0]) ? renderString(ariaNode.children[0] as string) : null;
if (text)
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(text));
else
lines.push(escapedKey);
} else {
lines.push(escapedKey + ':');
for (const child of ariaNode.children || [])
visit(child, ariaNode, indent + ' ');
}
}; };
if (ariaNode.role === 'fragment') { if (ariaNode.role === 'fragment') {
// Render fragment. // Render fragment.
for (const child of ariaNode.children || []) for (const child of ariaNode.children || [])
visit(child, ''); visit(child, ariaNode, '');
} else { } else {
visit(ariaNode, ''); visit(ariaNode, null, '');
} }
return lines.join('\n'); return lines.join('\n');
} }
function quoteYamlString(str: string) { function convertToBestGuessRegex(text: string): string {
return `"${str const dynamicContent = [
.replace(/\\/g, '\\\\') // 2mb
.replace(/"/g, '\\"') { regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: '[\\d,.]+[bkmBKM]+' },
.replace(/\n/g, '\\n') // 2ms, 20s
.replace(/\r/g, '\\r')}"`; { regex: /\b\d+[hmsp]+\b/, replacement: '\\d+[hmsp]+' },
{ regex: /\b[\d,.]+[hmsp]+\b/, replacement: '[\\d,.]+[hmsp]+' },
// Do not replace single digits with regex by default.
// 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
{ regex: /\b\d+,\d+\b/, replacement: '\\d+,\\d+' },
{ regex: /\b\d+\.\d{2,}\b/, replacement: '\\d+\\.\\d+' },
{ regex: /\b\d{2,}\.\d+\b/, replacement: '\\d+\\.\\d+' },
{ regex: /\b\d{2,}\b/, replacement: '\\d+' },
];
let pattern = '';
let lastIndex = 0;
const combinedRegex = new RegExp(dynamicContent.map(r => '(' + r.regex.source + ')').join('|'), 'g');
text.replace(combinedRegex, (match, ...args) => {
const offset = args[args.length - 2];
const groups = args.slice(0, -2);
pattern += escapeRegExp(text.slice(lastIndex, offset));
for (let i = 0; i < groups.length; i++) {
if (groups[i]) {
const { replacement } = dynamicContent[i];
pattern += replacement;
break;
}
}
lastIndex = offset + match.length;
return match;
});
if (!pattern)
return text;
pattern += escapeRegExp(text.slice(lastIndex));
return String(new RegExp(pattern));
}
function textContributesInfo(node: AriaNode, text: string): boolean {
if (!text.length)
return false;
if (!node.name)
return true;
if (node.name.length > text.length)
return false;
// Figure out if text adds any value.
const substr = longestCommonSubstring(text, node.name);
let filtered = text;
while (substr && filtered.includes(substr))
filtered = filtered.replace(substr, '');
return filtered.trim().length / text.length > 0.1;
} }

View file

@ -212,10 +212,10 @@ export class InjectedScript {
return new Set<Element>(result.map(r => r.element)); return new Set<Element>(result.map(r => r.element));
} }
ariaSnapshot(node: Node): string { ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex' }): string {
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
return renderedAriaTree(node as Element); return renderedAriaTree(node as Element, options);
} }
querySelectorAll(selector: ParsedSelector, root: Node): Element[] { querySelectorAll(selector: ParsedSelector, root: Node): Element[] {

View file

@ -715,7 +715,7 @@ class TextAssertionTool implements RecorderTool {
name: 'assertSnapshot', name: 'assertSnapshot',
selector: this._hoverHighlight.selector, selector: this._hoverHighlight.selector,
signals: [], signals: [],
snapshot: this._recorder.injectedScript.ariaSnapshot(target), snapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'regex' }),
}; };
} else { } else {
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });

View file

@ -0,0 +1,107 @@
/**
* 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 function yamlEscapeKeyIfNeeded(str: string): string {
if (!yamlStringNeedsQuotes(str))
return str;
return `'` + str.replace(/'/g, `''`) + `'`;
}
export function yamlEscapeValueIfNeeded(str: string): string {
if (!yamlStringNeedsQuotes(str))
return str;
return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => {
switch (c) {
case '\\':
return '\\\\';
case '"':
return '\\"';
case '\b':
return '\\b';
case '\f':
return '\\f';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
default:
const code = c.charCodeAt(0);
return '\\x' + code.toString(16).padStart(2, '0');
}
}) + '"';
}
export function yamlQuoteFragment(str: string, quote = '"'): string {
return quote + str.replace(/['"]/g, c => {
switch (c) {
case '"':
return quote === '"' ? '\\"' : '"';
case '\'':
return quote === '\'' ? '\\\'' : '\'';
default:
return c;
}
}) + quote;
}
function yamlStringNeedsQuotes(str: string): boolean {
if (str.length === 0)
return true;
// Strings with leading or trailing whitespace need quotes
if (/^\s|\s$/.test(str))
return true;
// Strings containing control characters need quotes
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
return true;
// Strings starting with '-' followed by a space need quotes
if (/^-\s/.test(str))
return true;
// Strings that start with a special indicator character need quotes
if (/^[&*].*/.test(str))
return true;
// Strings containing ':' followed by a space or at the end need quotes
if (/:(\s|$)/.test(str))
return true;
// Strings containing '#' preceded by a space need quotes (comment indicator)
if (/\s#/.test(str))
return true;
// Strings that contain line breaks need quotes
if (/[\n\r]/.test(str))
return true;
// Strings starting with '?' or '!' (directives) need quotes
if (/^[?!]/.test(str))
return true;
// Strings starting with '>' or '|' (block scalar indicators) need quotes
if (/^[>|]/.test(str))
return true;
// Strings containing special characters that could cause ambiguity
if (/[{}`]/.test(str))
return true;
return false;
}

View file

@ -31,7 +31,7 @@ import * as accessibility from './accessibility';
import { FileChooser } from './fileChooser'; import { FileChooser } from './fileChooser';
import type { Progress } from './progress'; import type { Progress } from './progress';
import { ProgressController } from './progress'; import { ProgressController } from './progress';
import { LongStandingScope, assert, createGuid, trimStringWithEllipsis } from '../utils'; import { LongStandingScope, assert, compressCallLog, createGuid, trimStringWithEllipsis } from '../utils';
import { ManualPromise } from '../utils/manualPromise'; import { ManualPromise } from '../utils/manualPromise';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import type { ImageComparatorOptions } from '../utils/comparators'; import type { ImageComparatorOptions } from '../utils/comparators';
@ -674,11 +674,12 @@ export class Page extends SdkObject {
throw e; throw e;
let errorMessage = e.message; let errorMessage = e.message;
if (e instanceof TimeoutError && intermediateResult?.previous) if (e instanceof TimeoutError && intermediateResult?.previous)
errorMessage = `Failed to take two consecutive stable screenshots. ${e.message}`; errorMessage = `Failed to take two consecutive stable screenshots.`;
return { return {
log: e.message ? [...metadata.log, e.message] : metadata.log, log: compressCallLog(e.message ? [...metadata.log, e.message] : metadata.log),
...intermediateResult, ...intermediateResult,
errorMessage, errorMessage,
timedOut: (e instanceof TimeoutError),
}; };
}); });
} }

View file

@ -36,6 +36,7 @@ import type { Frame } from './frames';
const recorderSymbol = Symbol('recorderSymbol'); const recorderSymbol = Symbol('recorderSymbol');
export class Recorder implements InstrumentationListener, IRecorder { export class Recorder implements InstrumentationListener, IRecorder {
readonly handleSIGINT: boolean | undefined;
private _context: BrowserContext; private _context: BrowserContext;
private _mode: Mode; private _mode: Mode;
private _highlightedSelector = ''; private _highlightedSelector = '';
@ -77,6 +78,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) { constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) {
this._mode = params.mode || 'none'; this._mode = params.mode || 'none';
this.handleSIGINT = params.handleSIGINT;
this._contextRecorder = new ContextRecorder(codegenMode, context, params, {}); this._contextRecorder = new ContextRecorder(codegenMode, context, params, {});
this._context = context; this._context = context;
this._omitCallTracking = !!params.omitCallTracking; this._omitCallTracking = !!params.omitCallTracking;

View file

@ -111,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
noDefaultViewport: true, noDefaultViewport: true,
headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed), headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed),
useWebSocket: isUnderTest(), useWebSocket: isUnderTest(),
handleSIGINT: false, handleSIGINT: recorder.handleSIGINT,
executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined,
} }
}); });

View file

@ -21,6 +21,7 @@ import type { EventEmitter } from 'events';
export interface IRecorder { export interface IRecorder {
setMode(mode: Mode): void; setMode(mode: Mode): void;
mode(): Mode; mode(): Mode;
readonly handleSIGINT: boolean | undefined;
} }
export interface IRecorderApp extends EventEmitter { export interface IRecorderApp extends EventEmitter {

View file

@ -125,14 +125,7 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[
for (const reporter of options.reporter || []) for (const reporter of options.reporter || [])
params.append('reporter', reporter); params.append('reporter', reporter);
let baseUrl = ''; const urlPath = `./trace/${options.webApp || 'index.html'}?${params.toString()}`;
if (process.env.PW_HMR === '1') {
params.set('testServerPort', '' + server.port());
baseUrl = 'http://localhost:44223'; // port is hardcoded in build.js
}
const urlPath = `${baseUrl}/trace/${options.webApp || 'index.html'}?${params.toString()}`;
server.routePath('/', (_, response) => { server.routePath('/', (_, response) => {
response.statusCode = 302; response.statusCode = 302;
response.setHeader('Location', urlPath); response.setHeader('Location', urlPath);
@ -170,6 +163,7 @@ export async function openTraceViewerApp(url: string, browserName: string, optio
...options?.persistentContextOptions, ...options?.persistentContextOptions,
useWebSocket: isUnderTest(), useWebSocket: isUnderTest(),
headless: !!options?.headless, headless: !!options?.headless,
colorScheme: isUnderTest() ? 'light' : undefined,
}, },
}); });

View file

@ -86,20 +86,15 @@ function calculatePlatform(): { hostPlatform: HostPlatform, isOfficiallySupporte
return { hostPlatform: ('ubuntu22.04' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform: false }; return { hostPlatform: ('ubuntu22.04' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform: false };
return { hostPlatform: ('ubuntu24.04' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform: false }; return { hostPlatform: ('ubuntu24.04' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform: false };
} }
if (distroInfo?.id === 'debian' || distroInfo?.id === 'raspbian' || distroInfo?.id === 'devuan') { if (distroInfo?.id === 'debian' || distroInfo?.id === 'raspbian') {
const isOfficiallySupportedPlatform = distroInfo?.id === 'debian'; const isOfficiallySupportedPlatform = distroInfo?.id === 'debian';
let debianVersion = distroInfo?.version; if (distroInfo?.version === '11')
if (distroInfo.id === 'devuan') {
// Devuan is debian-based but it's always 7 versions behind
debianVersion = String(parseInt(distroInfo.version, 10) + 7);
}
if (debianVersion === '11')
return { hostPlatform: ('debian11' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform }; return { hostPlatform: ('debian11' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform };
if (debianVersion === '12') if (distroInfo?.version === '12')
return { hostPlatform: ('debian12' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform }; return { hostPlatform: ('debian12' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform };
// use most recent supported release for 'debian testing' and 'unstable'. // use most recent supported release for 'debian testing' and 'unstable'.
// they never include a numeric version entry in /etc/os-release. // they never include a numeric version entry in /etc/os-release.
if (debianVersion === '') if (distroInfo?.version === '')
return { hostPlatform: ('debian12' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform }; return { hostPlatform: ('debian12' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform };
} }
return { hostPlatform: ('ubuntu20.04' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform: false }; return { hostPlatform: ('ubuntu20.04' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform: false };

View file

@ -22,6 +22,8 @@ export function buildFullSelector(framePath: string[], selector: string) {
return [...framePath, selector].join(' >> internal:control=enter-frame >> '); return [...framePath, selector].join(' >> internal:control=enter-frame >> ');
} }
const kDefaultTimeout = 5_000;
export function traceParamsForAction(actionInContext: recorderActions.ActionInContext): { method: string, params: any } { export function traceParamsForAction(actionInContext: recorderActions.ActionInContext): { method: string, params: any } {
const { action } = actionInContext; const { action } = actionInContext;
@ -101,6 +103,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
selector: action.selector, selector: action.selector,
expression: 'to.be.checked', expression: 'to.be.checked',
isNot: !action.checked, isNot: !action.checked,
timeout: kDefaultTimeout,
}; };
return { method: 'expect', params }; return { method: 'expect', params };
} }
@ -110,6 +113,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
expression: 'to.have.text', expression: 'to.have.text',
expectedText: [], expectedText: [],
isNot: false, isNot: false,
timeout: kDefaultTimeout,
}; };
return { method: 'expect', params }; return { method: 'expect', params };
} }
@ -119,6 +123,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
expression: 'to.have.value', expression: 'to.have.value',
expectedValue: undefined, expectedValue: undefined,
isNot: false, isNot: false,
timeout: kDefaultTimeout,
}; };
return { method: 'expect', params }; return { method: 'expect', params };
} }
@ -127,6 +132,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
selector, selector,
expression: 'to.be.visible', expression: 'to.be.visible',
isNot: false, isNot: false,
timeout: kDefaultTimeout,
}; };
return { method: 'expect', params }; return { method: 'expect', params };
} }
@ -136,6 +142,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
expression: 'to.match.snapshot', expression: 'to.match.snapshot',
expectedText: [], expectedText: [],
isNot: false, isNot: false,
timeout: kDefaultTimeout,
}; };
return { method: 'expect', params }; return { method: 'expect', params };
} }

View file

@ -27,6 +27,13 @@ export function escapeWithQuotes(text: string, char: string = '\'') {
throw new Error('Invalid escape char'); throw new Error('Invalid escape char');
} }
export function escapeTemplateString(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${');
}
export function isString(obj: any): obj is string { export function isString(obj: any): obj is string {
return typeof obj === 'string' || obj instanceof String; return typeof obj === 'string' || obj instanceof String;
} }
@ -140,3 +147,32 @@ export function escapeHTMLAttribute(s: string): string {
export function escapeHTML(s: string): string { export function escapeHTML(s: string): string {
return s.replace(/[&<]/ug, char => (escaped as any)[char]); return s.replace(/[&<]/ug, char => (escaped as any)[char]);
} }
export function longestCommonSubstring(s1: string, s2: string): string {
const n = s1.length;
const m = s2.length;
let maxLen = 0;
let endingIndex = 0;
// Initialize a 2D array with zeros
const dp = Array(n + 1)
.fill(null)
.map(() => Array(m + 1).fill(0));
// Build the dp table
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s1[i - 1] === s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
if (dp[i][j] > maxLen) {
maxLen = dp[i][j];
endingIndex = i;
}
}
}
}
// Extract the longest common substring
return s1.slice(endingIndex - maxLen, endingIndex);
}

View file

@ -131,7 +131,13 @@ export function splitErrorMessage(message: string): { name: string, message: str
export function formatCallLog(log: string[] | undefined): string { export function formatCallLog(log: string[] | undefined): string {
if (!log || !log.some(l => !!l)) if (!log || !log.some(l => !!l))
return ''; return '';
return `
Call log:
${colors.dim(log.join('\n'))}
`;
}
export function compressCallLog(log: string[]): string[] {
const lines: string[] = []; const lines: string[] = [];
for (const block of findRepeatedSubsequences(log)) { for (const block of findRepeatedSubsequences(log)) {
@ -148,10 +154,7 @@ export function formatCallLog(log: string[] | undefined): string {
lines.push(whitespacePrefix + '- ' + line.trim()); lines.push(whitespacePrefix + '- ' + line.trim());
} }
} }
return ` return lines;
Call log:
${colors.dim(lines.join('\n'))}
`;
} }
export type ExpectZone = { export type ExpectZone = {

View file

@ -43,12 +43,8 @@ import type { StackFrame } from '@protocol/channels';
const StackUtils: typeof import('../bundles/utils/node_modules/@types/stack-utils') = require('./utilsBundleImpl').StackUtils; const StackUtils: typeof import('../bundles/utils/node_modules/@types/stack-utils') = require('./utilsBundleImpl').StackUtils;
const stackUtils = new StackUtils({ internals: StackUtils.nodeInternals() }); const stackUtils = new StackUtils({ internals: StackUtils.nodeInternals() });
const nodeInternals = StackUtils.nodeInternals();
const nodeMajorVersion = +process.versions.node.split('.')[0];
export function parseStackTraceLine(line: string): StackFrame | null { export function parseStackTraceLine(line: string): StackFrame | null {
if (!process.env.PWDEBUGIMPL && nodeMajorVersion < 16 && nodeInternals.some(internal => internal.test(line)))
return null;
const frame = stackUtils.parseLine(line); const frame = stackUtils.parseLine(line);
if (!frame) if (!frame)
return null; return null;

View file

@ -19819,10 +19819,7 @@ export interface FrameLocator {
* An example to trigger select-all with the keyboard * An example to trigger select-all with the keyboard
* *
* ```js * ```js
* // on Windows and Linux * await page.keyboard.press('ControlOrMeta+A');
* await page.keyboard.press('Control+A');
* // on macOS
* await page.keyboard.press('Meta+A');
* ``` * ```
* *
*/ */

View file

@ -1,12 +0,0 @@
**/*
!README.md
!LICENSE
!cli.js
!register.d.ts
!register.mjs
!registerSource.mjs
!index.d.ts
!index.js
!hooks.d.ts
!hooks.mjs

View file

@ -1,3 +0,0 @@
> **BEWARE** This package is EXPERIMENTAL and does not respect semver.
Read more at https://playwright.dev/docs/test-components

View file

@ -1,20 +0,0 @@
#!/usr/bin/env node
/**
* 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.
*/
const { program } = require('@playwright/experimental-ct-core/lib/program');
program.parse(process.argv);

View file

@ -1,37 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ComponentOptions } from 'vue';
import type { CombinedVueInstance, Vue, VueConstructor } from 'vue/types/vue';
export declare function beforeMount<HooksConfig>(
callback: (params: {
hooksConfig?: HooksConfig,
Vue: VueConstructor<Vue>,
}) => Promise<void | ComponentOptions<Vue> & Record<string, unknown>>
): void;
export declare function afterMount<HooksConfig>(
callback: (params: {
hooksConfig?: HooksConfig;
instance: CombinedVueInstance<
Vue,
object,
object,
object,
Record<never, any>
>;
}) => Promise<void>
): void;

View file

@ -1,29 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const __pw_hooks_before_mount = [];
const __pw_hooks_after_mount = [];
window.__pw_hooks_before_mount = __pw_hooks_before_mount;
window.__pw_hooks_after_mount = __pw_hooks_after_mount;
export const beforeMount = callback => {
__pw_hooks_before_mount.push(callback);
};
export const afterMount = callback => {
__pw_hooks_after_mount.push(callback);
};

View file

@ -1,66 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { TestType, Locator } from '@playwright/experimental-ct-core';
type Slot = string | string[];
type ComponentSlots = Record<string, Slot> & { default?: Slot };
type ComponentEvents = Record<string, Function>;
// Copied from: https://github.com/vuejs/language-tools/blob/master/packages/vue-component-type-helpers/index.d.ts#L10-L13
type ComponentProps<T> =
T extends new (...angs: any) => { $props: infer P; } ? NonNullable<P> :
T extends (props: infer P, ...args: any) => any ? P :
{};
export interface MountOptions<HooksConfig, Component> {
props?: ComponentProps<Component>;
slots?: ComponentSlots;
on?: ComponentEvents;
hooksConfig?: HooksConfig;
}
export interface MountOptionsJsx<HooksConfig> {
hooksConfig?: HooksConfig;
}
export interface MountResult<Component> extends Locator {
unmount(): Promise<void>;
update(options: {
props?: Partial<ComponentProps<Component>>;
slots?: Partial<ComponentSlots>;
on?: Partial<ComponentEvents>;
}): Promise<void>;
}
export interface MountResultJsx extends Locator {
unmount(): Promise<void>;
update(component: JSX.Element): Promise<void>;
}
export const test: TestType<{
mount<HooksConfig>(
component: JSX.Element,
options?: MountOptionsJsx<HooksConfig>
): Promise<MountResultJsx>;
mount<HooksConfig, Component = unknown>(
component: Component,
options?: MountOptions<HooksConfig, Component>
): Promise<MountResult<Component>>;
}>;
export { defineConfig, PlaywrightTestConfig, expect, devices } from '@playwright/experimental-ct-core';

View file

@ -1,33 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
const path = require('path');
const defineConfig = (config, ...configs) => {
return originalDefineConfig({
...config,
'@playwright/test': {
packageJSON: require.resolve('./package.json'),
},
'@playwright/experimental-ct-core': {
registerSourceFile: path.join(__dirname, 'registerSource.mjs'),
frameworkPluginFactory: () => import('@vitejs/plugin-vue2').then(plugin => plugin.default()),
},
}, ...configs);
};
module.exports = { test, expect, devices, defineConfig };

View file

@ -1,42 +0,0 @@
{
"name": "@playwright/experimental-ct-vue2",
"version": "1.49.0-next",
"description": "Playwright Component Testing for Vue2",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
},
"./register": {
"types": "./register.d.ts",
"default": "./register.mjs"
},
"./hooks": {
"types": "./hooks.d.ts",
"default": "./hooks.mjs"
},
"./package.json": "./package.json"
},
"dependencies": {
"@playwright/experimental-ct-core": "1.49.0-next",
"@vitejs/plugin-vue2": "^2.2.0"
},
"devDependencies": {
"vue": "^2.7.14"
},
"bin": {
"playwright": "cli.js"
}
}

View file

@ -1,24 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default function pwRegister(
components: Record<string, any>,
options?: {
createApp: any,
setDevtoolsHook: any,
h: any,
}
): void;

View file

@ -1,21 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { pwRegister } from './registerSource.mjs';
export default components => {
pwRegister(components);
};

View file

@ -1,212 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
// This file is injected into the registry as text, no dependencies are allowed.
import __pwVue, { h as __pwH } from 'vue';
/** @typedef {import('../playwright-ct-core/types/component').Component} Component */
/** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */
/** @typedef {import('../playwright-ct-core/types/component').ObjectComponent} ObjectComponent */
/** @typedef {import('vue').Component} FrameworkComponent */
/**
* @param {any} component
* @returns {component is ObjectComponent}
*/
function isObjectComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'object-component';
}
/**
* @param {any} component
* @returns {component is JsxComponent}
*/
function isJsxComponent(component) {
return typeof component === 'object' && component && component.__pw_type === 'jsx';
}
/**
* @param {any} child
*/
function __pwCreateChild(child) {
if (Array.isArray(child))
return child.map(grandChild => __pwCreateChild(grandChild));
if (isJsxComponent(child) || isObjectComponent(child))
return __pwCreateWrapper(child);
return child;
}
/**
* Exists to support fallthrough attributes:
* https://vuejs.org/guide/components/attrs.html#fallthrough-attributes
* @param {any} Component
* @param {string} key
* @return {boolean}
*/
function __pwComponentHasKeyInProps(Component, key) {
return typeof Component.props === 'object' && Component.props && key in Component.props;
}
/**
* @param {JsxComponent} component
* @returns {any[] | undefined}
*/
function __pwJsxChildArray(component) {
if (!component.props.children)
return;
if (Array.isArray(component.props.children))
return component.props.children;
return [component.props.children];
}
/**
* @param {Component} component
*/
function __pwCreateComponent(component) {
const isVueComponent = typeof component.type !== 'string';
/**
* @type {(import('vue').VNode | string)[]}
*/
const children = [];
/** @type {import('vue').VNodeData} */
const nodeData = {};
nodeData.attrs = {};
nodeData.props = {};
nodeData.scopedSlots = {};
nodeData.on = {};
if (component.__pw_type === 'jsx') {
for (const child of __pwJsxChildArray(component) || []) {
if (isJsxComponent(child) && child.type === 'template') {
const slotProperty = Object.keys(child.props).find(k => k.startsWith('v-slot:'));
const slot = slotProperty ? slotProperty.substring('v-slot:'.length) : 'default';
nodeData.scopedSlots[slot] = () => __pwJsxChildArray(child)?.map(c => __pwCreateChild(c));
} else {
children.push(__pwCreateChild(child));
}
}
for (const [key, value] of Object.entries(component.props)) {
if (key.startsWith('v-on:')) {
const event = key.substring('v-on:'.length);
nodeData.on[event] = value;
} else {
if (isVueComponent && __pwComponentHasKeyInProps(component.type, key))
nodeData.props[key] = value;
else
nodeData.attrs[key] = value;
}
}
}
if (component.__pw_type === 'object-component') {
// Vue test util syntax.
for (const [key, value] of Object.entries(component.slots || {})) {
const list = (Array.isArray(value) ? value : [value]).map(v => __pwCreateChild(v));
if (key === 'default')
children.push(...list);
else
nodeData.scopedSlots[key] = () => list;
}
nodeData.props = component.props || {};
for (const [key, value] of Object.entries(component.on || {}))
nodeData.on[key] = value;
}
/** @type {(string|import('vue').VNode)[] | undefined} */
let lastArg;
if (Object.entries(nodeData.scopedSlots).length) {
if (children.length)
nodeData.scopedSlots.default = () => children;
} else if (children.length) {
lastArg = children;
}
return { Component: component.type, nodeData, slots: lastArg };
}
/**
* @param {Component} component
* @returns {import('vue').VNode}
*/
function __pwCreateWrapper(component) {
const { Component, nodeData, slots } = __pwCreateComponent(component);
const wrapper = __pwH(Component, nodeData, slots);
return wrapper;
}
const instanceKey = Symbol('instanceKey');
const wrapperKey = Symbol('wrapperKey');
window.playwrightMount = async (component, rootElement, hooksConfig) => {
let options = {};
for (const hook of window.__pw_hooks_before_mount || [])
options = await hook({ hooksConfig, Vue: __pwVue });
const instance = new __pwVue({
...options,
render: () => {
const wrapper = __pwCreateWrapper(component);
/** @type {any} */ (rootElement)[wrapperKey] = wrapper;
return wrapper;
},
}).$mount();
rootElement.appendChild(instance.$el);
/** @type {any} */ (rootElement)[instanceKey] = instance;
for (const hook of window.__pw_hooks_after_mount || [])
await hook({ hooksConfig, instance });
};
window.playwrightUnmount = async rootElement => {
const component = rootElement[instanceKey];
if (!component)
throw new Error('Component was not mounted');
component.$destroy();
component.$el.remove();
delete rootElement[instanceKey];
};
window.playwrightUpdate = async (element, options) => {
const wrapper = /** @type {any} */(element)[wrapperKey];
if (!wrapper)
throw new Error('Component was not mounted');
const component = wrapper.componentInstance;
if (!component)
throw new Error('Updating a native HTML element is not supported');
const { nodeData, slots } = __pwCreateComponent(options);
for (const [name, value] of Object.entries(nodeData.on || {})) {
component.$on(name, value);
component.$listeners[name] = value;
}
Object.assign(component.$scopedSlots, nodeData.scopedSlots);
component.$slots.default = slots;
for (const [key, value] of Object.entries(nodeData.props || {}))
component[key] = value;
if (!Object.keys(nodeData.props || {}).length)
component.$forceUpdate();
};

View file

@ -112,6 +112,7 @@ This project incorporates components from the projects listed below. The origina
- escape-string-regexp@2.0.0 (https://github.com/sindresorhus/escape-string-regexp) - escape-string-regexp@2.0.0 (https://github.com/sindresorhus/escape-string-regexp)
- fill-range@7.1.1 (https://github.com/jonschlinkert/fill-range) - fill-range@7.1.1 (https://github.com/jonschlinkert/fill-range)
- gensync@1.0.0-beta.2 (https://github.com/loganfsmyth/gensync) - gensync@1.0.0-beta.2 (https://github.com/loganfsmyth/gensync)
- get-east-asian-width@1.3.0 (https://github.com/sindresorhus/get-east-asian-width)
- glob-parent@5.1.2 (https://github.com/gulpjs/glob-parent) - glob-parent@5.1.2 (https://github.com/gulpjs/glob-parent)
- globals@11.12.0 (https://github.com/sindresorhus/globals) - globals@11.12.0 (https://github.com/sindresorhus/globals)
- graceful-fs@4.2.11 (https://github.com/isaacs/node-graceful-fs) - graceful-fs@4.2.11 (https://github.com/isaacs/node-graceful-fs)
@ -3410,6 +3411,20 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
========================================= =========================================
END OF gensync@1.0.0-beta.2 AND INFORMATION END OF gensync@1.0.0-beta.2 AND INFORMATION
%% get-east-asian-width@1.3.0 NOTICES AND INFORMATION BEGIN HERE
=========================================
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
=========================================
END OF get-east-asian-width@1.3.0 AND INFORMATION
%% glob-parent@5.1.2 NOTICES AND INFORMATION BEGIN HERE %% glob-parent@5.1.2 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
The ISC License The ISC License
@ -4399,6 +4414,6 @@ END OF yallist@3.1.1 AND INFORMATION
SUMMARY BEGIN HERE SUMMARY BEGIN HERE
========================================= =========================================
Total Packages: 151 Total Packages: 152
========================================= =========================================
END OF SUMMARY END OF SUMMARY

View file

@ -23,7 +23,6 @@ import * as babel from '@babel/core';
export { codeFrameColumns } from '@babel/code-frame'; export { codeFrameColumns } from '@babel/code-frame';
export { declare } from '@babel/helper-plugin-utils'; export { declare } from '@babel/helper-plugin-utils';
export { types } from '@babel/core'; export { types } from '@babel/core';
export { parse } from '@babel/parser';
import traverseFunction from '@babel/traverse'; import traverseFunction from '@babel/traverse';
export const traverse = traverseFunction; export const traverse = traverseFunction;
@ -114,16 +113,25 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins
let isTransforming = false; let isTransforming = false;
export function babelTransform(code: string, filename: string, isTypeScript: boolean, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult { function isTypeScript(filename: string) {
return filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts');
}
export function babelTransform(code: string, filename: string, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult {
if (isTransforming) if (isTransforming)
return {}; return {};
// Prevent reentry while requiring plugins lazily. // Prevent reentry while requiring plugins lazily.
isTransforming = true; isTransforming = true;
try { try {
const options = babelTransformOptions(isTypeScript, isModule, pluginsPrologue, pluginsEpilogue); const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue);
return babel.transform(code, { filename, ...options })!; return babel.transform(code, { filename, ...options })!;
} finally { } finally {
isTransforming = false; isTransforming = false;
} }
} }
export function babelParse(code: string, filename: string, isModule: boolean): babel.ParseResult {
const options = babelTransformOptions(isTypeScript(filename), isModule, [], []);
return babel.parse(code, { filename, ...options })!;
}

View file

@ -16,35 +16,14 @@
// @ts-check // @ts-check
const path = require('path'); const path = require('path');
const fs = require('fs');
const esbuild = require('esbuild'); const esbuild = require('esbuild');
// Can be removed once source-map-support was is fixed.
/** @type{import('esbuild').Plugin} */
let patchSource = {
name: 'patch-source-map-support-deprecation',
setup(build) {
build.onResolve({ filter: /^source-map-support$/ }, () => {
const originalPath = require.resolve('source-map-support');
const patchedPath = path.join(path.dirname(originalPath), path.basename(originalPath, '.js') + '.pw-patched.js');
let sourceFileContent = fs.readFileSync(originalPath, 'utf8');
// source-map-support is overwriting __PW_ZONE__ with func in core if source maps are present.
const original = `return state.nextPosition.name || originalFunctionName();`;
const insertedLine = `if (state.nextPosition.name === 'func') return originalFunctionName() || 'func';`;
sourceFileContent = sourceFileContent.replace(original, insertedLine + original);
fs.writeFileSync(patchedPath, sourceFileContent);
return { path: patchedPath }
});
},
};
(async () => { (async () => {
const ctx = await esbuild.context({ const ctx = await esbuild.context({
entryPoints: [path.join(__dirname, 'src/utilsBundleImpl.ts')], entryPoints: [path.join(__dirname, 'src/utilsBundleImpl.ts')],
external: ['fsevents'], external: ['fsevents'],
bundle: true, bundle: true,
outdir: path.join(__dirname, '../../lib'), outdir: path.join(__dirname, '../../lib'),
plugins: [patchSource],
format: 'cjs', format: 'cjs',
platform: 'node', platform: 'node',
target: 'ES2019', target: 'ES2019',

View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"chokidar": "3.6.0", "chokidar": "3.6.0",
"enquirer": "2.3.6", "enquirer": "2.3.6",
"get-east-asian-width": "1.3.0",
"json5": "2.2.3", "json5": "2.2.3",
"pirates": "4.0.4", "pirates": "4.0.4",
"source-map-support": "0.5.21", "source-map-support": "0.5.21",
@ -146,6 +147,18 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-east-asian-width": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@ -376,6 +389,11 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true "optional": true
}, },
"get-east-asian-width": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="
},
"glob-parent": { "glob-parent": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",

View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"chokidar": "3.6.0", "chokidar": "3.6.0",
"enquirer": "2.3.6", "enquirer": "2.3.6",
"get-east-asian-width": "1.3.0",
"json5": "2.2.3", "json5": "2.2.3",
"pirates": "4.0.4", "pirates": "4.0.4",
"source-map-support": "0.5.21", "source-map-support": "0.5.21",

View file

@ -31,3 +31,6 @@ export const enquirer = enquirerLibrary;
import chokidarLibrary from 'chokidar'; import chokidarLibrary from 'chokidar';
export const chokidar = chokidarLibrary; export const chokidar = chokidarLibrary;
import * as getEastAsianWidthLibrary from 'get-east-asian-width';
export const getEastAsianWidth = getEastAsianWidthLibrary;

View file

@ -378,11 +378,6 @@ export function restartWithExperimentalTsEsm(configFile: string | undefined, for
// Now check for the newer API presence. // Now check for the newer API presence.
if (!require('node:module').register) { if (!require('node:module').register) {
// Older API is experimental, only supported on Node 16+.
const nodeVersion = +process.versions.node.split('.')[0];
if (nodeVersion < 16)
return false;
// With older API requiring a process restart, do so conditionally on the config. // With older API requiring a process restart, do so conditionally on the config.
const configIsModule = !!configFile && fileIsModule(configFile); const configIsModule = !!configFile && fileIsModule(configFile);
if (!force && !configIsModule) if (!force && !configIsModule)

View file

@ -106,6 +106,7 @@ export type StepEndPayload = {
stepId: string; stepId: string;
wallTime: number; // milliseconds since unix epoch wallTime: number; // milliseconds since unix epoch
error?: TestInfoErrorImpl; error?: TestInfoErrorImpl;
suggestedRebaseline?: string;
}; };
export type TestEntry = { export type TestEntry = {

View file

@ -61,7 +61,7 @@ import {
} from '../common/expectBundle'; } from '../common/expectBundle';
import { zones } from 'playwright-core/lib/utils'; import { zones } from 'playwright-core/lib/utils';
import { TestInfoImpl } from '../worker/testInfo'; import { TestInfoImpl } from '../worker/testInfo';
import { ExpectError, isExpectError } from './matcherHint'; import { ExpectError, isJestError } from './matcherHint';
import { toMatchAriaSnapshot } from './toMatchAriaSnapshot'; import { toMatchAriaSnapshot } from './toMatchAriaSnapshot';
// #region // #region
@ -323,8 +323,13 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
const step = testInfo._addStep(stepInfo); const step = testInfo._addStep(stepInfo);
const reportStepError = (jestError: Error | unknown) => { const reportStepError = (e: Error | unknown) => {
const error = isExpectError(jestError) ? new ExpectError(jestError, customMessage, stackFrames) : jestError; const jestError = isJestError(e) ? e : null;
const error = jestError ? new ExpectError(jestError, customMessage, stackFrames) : e;
if (jestError?.matcherResult.suggestedRebaseline) {
step.complete({ suggestedRebaseline: jestError?.matcherResult.suggestedRebaseline });
return;
}
step.complete({ error }); step.complete({ error });
if (this._info.isSoft) if (this._info.isSoft)
testInfo._failWithError(error); testInfo._failWithError(error);

View file

@ -33,16 +33,13 @@ export function matcherHint(state: ExpectMatcherState, locator: Locator | undefi
export type MatcherResult<E, A> = { export type MatcherResult<E, A> = {
name: string; name: string;
expected: E; expected?: E;
message: () => string; message: () => string;
pass: boolean; pass: boolean;
actual?: A; actual?: A;
log?: string[]; log?: string[];
timeout?: number; timeout?: number;
locator?: string; suggestedRebaseline?: string;
printedReceived?: string;
printedExpected?: string;
printedDiff?: string;
}; };
export type MatcherResultProperty = Omit<MatcherResult<unknown, unknown>, 'message'> & { export type MatcherResultProperty = Omit<MatcherResult<unknown, unknown>, 'message'> & {
@ -69,6 +66,6 @@ export class ExpectError extends Error {
} }
} }
export function isExpectError(e: unknown): e is ExpectError { export function isJestError(e: unknown): e is JestError {
return e instanceof Error && 'matcherResult' in e; return e instanceof Error && 'matcherResult' in e;
} }

View file

@ -15,7 +15,7 @@
*/ */
import type { Locator, Page, APIResponse } from 'playwright-core'; import type { Locator, Page, APIResponse } from 'playwright-core';
import type { FrameExpectOptions } from 'playwright-core/lib/client/types'; import type { FrameExpectParams } from 'playwright-core/lib/client/types';
import { colors } from 'playwright-core/lib/utilsBundle'; import { colors } from 'playwright-core/lib/utilsBundle';
import { expectTypes, callLogText } from '../util'; import { expectTypes, callLogText } from '../util';
import { toBeTruthy } from './toBeTruthy'; import { toBeTruthy } from './toBeTruthy';
@ -28,7 +28,7 @@ import type { ExpectMatcherState } from '../../types/test';
import { takeFirst } from '../common/config'; import { takeFirst } from '../common/config';
export interface LocatorEx extends Locator { export interface LocatorEx extends Locator {
_expect(expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
} }
interface APIResponseEx extends APIResponse { interface APIResponseEx extends APIResponse {

View file

@ -73,7 +73,5 @@ export async function toBeTruthy(
expected, expected,
log, log,
timeout: timedOut ? timeout : undefined, timeout: timedOut ? timeout : undefined,
...(printedReceived ? { printedReceived } : {}),
...(printedExpected ? { printedExpected } : {}),
}; };
} }

View file

@ -83,8 +83,5 @@ export async function toEqual<T>(
pass, pass,
log, log,
timeout: timedOut ? timeout : undefined, timeout: timedOut ? timeout : undefined,
...(printedReceived ? { printedReceived } : {}),
...(printedExpected ? { printedExpected } : {}),
...(printedDiff ? { printedDiff } : {}),
}; };
} }

View file

@ -22,6 +22,9 @@ import { colors } from 'playwright-core/lib/utilsBundle';
import { EXPECTED_COLOR } from '../common/expectBundle'; import { EXPECTED_COLOR } from '../common/expectBundle';
import { callLogText } from '../util'; import { callLogText } from '../util';
import { printReceivedStringContainExpectedSubstring } from './expect'; import { printReceivedStringContainExpectedSubstring } from './expect';
import { currentTestInfo } from '../common/globals';
import type { MatcherReceived } from '@injected/ariaSnapshot';
import { escapeTemplateString } from 'playwright-core/lib/utils';
export async function toMatchAriaSnapshot( export async function toMatchAriaSnapshot(
this: ExpectMatcherState, this: ExpectMatcherState,
@ -31,6 +34,15 @@ export async function toMatchAriaSnapshot(
): Promise<MatcherResult<string | RegExp, string>> { ): Promise<MatcherResult<string | RegExp, string>> {
const matcherName = 'toMatchAriaSnapshot'; const matcherName = 'toMatchAriaSnapshot';
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`toMatchAriaSnapshot() must be called during the test`);
if (testInfo._projectInternal.ignoreSnapshots)
return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected };
const updateSnapshots = testInfo.config.updateSnapshots;
const matcherOptions = { const matcherOptions = {
isNot: this.isNot, isNot: this.isNot,
promise: this.promise, promise: this.promise,
@ -44,27 +56,57 @@ export async function toMatchAriaSnapshot(
].join('\n\n')); ].join('\n\n'));
} }
const generateMissingBaseline = updateSnapshots === 'missing' && !expected;
const generateNewBaseline = updateSnapshots === 'all' || generateMissingBaseline;
if (generateMissingBaseline) {
if (this.isNot) {
const message = `Matchers using ".not" can't generate new baselines`;
return { pass: this.isNot, message: () => message, name: 'toMatchAriaSnapshot' };
} else {
// When generating new baseline, run entire pipeline against impossible match.
expected = `- none "Generating new baseline"`;
}
}
const timeout = options.timeout ?? this.timeout; const timeout = options.timeout ?? this.timeout;
expected = unshift(expected);
const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout }); const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout });
const typedReceived = received as MatcherReceived | typeof kNoElementsFoundError;
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError; const notFound = typedReceived === kNoElementsFoundError;
const escapedExpected = unshift(escapePrivateUsePoints(expected)); if (notFound) {
const escapedReceived = unshift(escapePrivateUsePoints(received)); return {
pass: this.isNot,
message: () => messagePrefix + `Expected: ${this.utils.printExpected(expected)}\nReceived: ${EXPECTED_COLOR('not found')}` + callLogText(log),
name: 'toMatchAriaSnapshot',
expected,
};
}
const escapedExpected = escapePrivateUsePoints(expected);
const escapedReceived = escapePrivateUsePoints(typedReceived.raw);
const message = () => { const message = () => {
if (pass) { if (pass) {
if (notFound) if (notFound)
return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log); return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
const printedReceived = printReceivedStringContainExpectedSubstring(escapedReceived, escapedReceived.indexOf(escapedExpected), escapedExpected.length); const printedReceived = printReceivedStringContainExpectedSubstring(escapedReceived, escapedReceived.indexOf(escapedExpected), escapedExpected.length);
return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived string: ${printedReceived}` + callLogText(log); return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${printedReceived}` + callLogText(log);
} else { } else {
const labelExpected = `Expected`; const labelExpected = `Expected`;
if (notFound) if (notFound)
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log); return messagePrefix + `${labelExpected}: ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
return messagePrefix + this.utils.printDiffOrStringify(escapedExpected, escapedReceived, labelExpected, 'Received string', false) + callLogText(log); return messagePrefix + this.utils.printDiffOrStringify(escapedExpected, escapedReceived, labelExpected, 'Received', false) + callLogText(log);
} }
}; };
if (!this.isNot && pass === this.isNot && generateNewBaseline) {
// Only rebaseline failed snapshots.
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`;
return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline };
}
return { return {
name: matcherName, name: matcherName,
expected, expected,
@ -77,7 +119,7 @@ export async function toMatchAriaSnapshot(
} }
function escapePrivateUsePoints(str: string) { function escapePrivateUsePoints(str: string) {
return str.replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`); return escapeTemplateString(str).replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
} }
function unshift(snapshot: string): string { function unshift(snapshot: string): string {
@ -93,3 +135,7 @@ function unshift(snapshot: string): string {
} }
return lines.filter(t => t.trim()).map(line => line.substring(whitespacePrefixLength)).join('\n'); return lines.filter(t => t.trim()).map(line => line.substring(whitespacePrefixLength)).join('\n');
} }
function indent(snapshot: string, indent: string): string {
return snapshot.split('\n').map(line => indent + line).join('\n');
}

View file

@ -18,7 +18,7 @@ import type { Locator, Page } from 'playwright-core';
import type { ExpectScreenshotOptions, Page as PageEx } from 'playwright-core/lib/client/page'; import type { ExpectScreenshotOptions, Page as PageEx } from 'playwright-core/lib/client/page';
import { currentTestInfo } from '../common/globals'; import { currentTestInfo } from '../common/globals';
import type { ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils'; import type { ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils';
import { getComparator, sanitizeForFilePath } from 'playwright-core/lib/utils'; import { getComparator, isString, sanitizeForFilePath } from 'playwright-core/lib/utils';
import { import {
addSuffixToFilePath, addSuffixToFilePath,
trimLongString, callLogText, trimLongString, callLogText,
@ -31,7 +31,7 @@ import path from 'path';
import { mime } from 'playwright-core/lib/utilsBundle'; import { mime } from 'playwright-core/lib/utilsBundle';
import type { TestInfoImpl } from '../worker/testInfo'; import type { TestInfoImpl } from '../worker/testInfo';
import type { ExpectMatcherState } from '../../types/test'; import type { ExpectMatcherState } from '../../types/test';
import type { MatcherResult } from './matcherHint'; import { matcherHint, type MatcherResult } from './matcherHint';
import type { FullProjectInternal } from '../common/config'; import type { FullProjectInternal } from '../common/config';
type NameOrSegments = string | string[]; type NameOrSegments = string | string[];
@ -194,10 +194,6 @@ class SnapshotHelper {
pass, pass,
message: () => message, message: () => message,
log, log,
// eslint-disable-next-line @typescript-eslint/no-base-to-string
...(this.locator ? { locator: this.locator.toString() } : {}),
printedExpected: this.expectedPath,
printedReceived: this.actualPath,
}; };
return Object.fromEntries(Object.entries(unfiltered).filter(([_, v]) => v !== undefined)) as ImageMatcherResult; return Object.fromEntries(Object.entries(unfiltered).filter(([_, v]) => v !== undefined)) as ImageMatcherResult;
} }
@ -250,16 +246,10 @@ class SnapshotHelper {
expected: Buffer | string | undefined, expected: Buffer | string | undefined,
previous: Buffer | string | undefined, previous: Buffer | string | undefined,
diff: Buffer | string | undefined, diff: Buffer | string | undefined,
diffError: string | undefined, header: string,
log: string[] | undefined, diffError: string,
title = `${this.kind} comparison failed:`): ImageMatcherResult { log: string[] | undefined): ImageMatcherResult {
const output = [ const output = [`${header}${indent(diffError, ' ')}`];
colors.red(title),
'',
];
if (diffError)
output.push(indent(diffError, ' '));
if (expected !== undefined) { if (expected !== undefined) {
// Copy the expectation inside the `test-results/` folder for backwards compatibility, // Copy the expectation inside the `test-results/` folder for backwards compatibility,
// so that one can upload `test-results/` directory and have all the data inside. // so that one can upload `test-results/` directory and have all the data inside.
@ -338,7 +328,9 @@ export function toMatchSnapshot(
return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true); return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true);
} }
return helper.handleDifferent(received, expected, undefined, result.diff, result.errorMessage, undefined); const receiver = isString(received) ? 'string' : 'Buffer';
const header = matcherHint(this, undefined, 'toMatchSnapshot', receiver, undefined, undefined);
return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined);
} }
export function toHaveScreenshotStepTitle( export function toHaveScreenshotStepTitle(
@ -374,6 +366,7 @@ export async function toHaveScreenshot(
throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have '.png' extension`); throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have '.png' extension`);
expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot');
const style = await loadScreenshotStyles(helper.options.stylePath); const style = await loadScreenshotStyles(helper.options.stylePath);
const timeout = helper.options.timeout ?? this.timeout;
const expectScreenshotOptions: ExpectScreenshotOptions = { const expectScreenshotOptions: ExpectScreenshotOptions = {
locator, locator,
animations: helper.options.animations ?? 'disabled', animations: helper.options.animations ?? 'disabled',
@ -386,7 +379,7 @@ export async function toHaveScreenshot(
scale: helper.options.scale ?? 'css', scale: helper.options.scale ?? 'css',
style, style,
isNot: !!this.isNot, isNot: !!this.isNot,
timeout: helper.options.timeout ?? this.timeout, timeout,
comparator: helper.options.comparator, comparator: helper.options.comparator,
maxDiffPixels: helper.options.maxDiffPixels, maxDiffPixels: helper.options.maxDiffPixels,
maxDiffPixelRatio: helper.options.maxDiffPixelRatio, maxDiffPixelRatio: helper.options.maxDiffPixelRatio,
@ -410,13 +403,16 @@ export async function toHaveScreenshot(
if (helper.updateSnapshots === 'none' && !hasSnapshot) if (helper.updateSnapshots === 'none' && !hasSnapshot)
return helper.createMatcherResult(`A snapshot doesn't exist at ${helper.expectedPath}.`, false); return helper.createMatcherResult(`A snapshot doesn't exist at ${helper.expectedPath}.`, false);
const receiver = locator ? 'locator' : 'page';
if (!hasSnapshot) { if (!hasSnapshot) {
// Regenerate a new screenshot by waiting until two screenshots are the same. // Regenerate a new screenshot by waiting until two screenshots are the same.
const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions); const { actual, previous, diff, errorMessage, log, timedOut } = await page._expectScreenshot(expectScreenshotOptions);
// We tried re-generating new snapshot but failed. // We tried re-generating new snapshot but failed.
// This can be due to e.g. spinning animation, so we want to show it as a diff. // This can be due to e.g. spinning animation, so we want to show it as a diff.
if (errorMessage) if (errorMessage) {
return helper.handleDifferent(actual, undefined, previous, diff, undefined, log, errorMessage); const header = matcherHint(this, locator, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined);
return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log);
}
// We successfully generated new screenshot. // We successfully generated new screenshot.
return helper.handleMissing(actual!); return helper.handleMissing(actual!);
@ -427,7 +423,7 @@ export async function toHaveScreenshot(
// - regular matcher (i.e. not a `.not`) // - regular matcher (i.e. not a `.not`)
// - perhaps an 'all' flag to update non-matching screenshots // - perhaps an 'all' flag to update non-matching screenshots
expectScreenshotOptions.expected = await fs.promises.readFile(helper.expectedPath); expectScreenshotOptions.expected = await fs.promises.readFile(helper.expectedPath);
const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions); const { actual, previous, diff, errorMessage, log, timedOut } = await page._expectScreenshot(expectScreenshotOptions);
if (!errorMessage) if (!errorMessage)
return helper.handleMatching(); return helper.handleMatching();
@ -440,7 +436,8 @@ export async function toHaveScreenshot(
return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true); return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true);
} }
return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, errorMessage, log); const header = matcherHint(this, undefined, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined);
return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log);
} }
function writeFileSync(aPath: string, content: Buffer | string) { function writeFileSync(aPath: string, content: Buffer | string) {

View file

@ -118,10 +118,5 @@ export async function toMatchText(
actual: received, actual: received,
log, log,
timeout: timedOut ? timeout : undefined, timeout: timedOut ? timeout : undefined,
// eslint-disable-next-line @typescript-eslint/no-base-to-string
locator: receiver.toString(),
...(printedReceived ? { printedReceived } : {}),
...(printedExpected ? { printedExpected } : {}),
...(printedDiff ? { printedDiff } : {}),
}; };
} }

View file

@ -18,6 +18,7 @@ import { colors as realColors, ms as milliseconds, parseStackTraceLine } from 'p
import path from 'path'; import path from 'path';
import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter'; import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter';
import { getPackageManagerExecCommand } from 'playwright-core/lib/utils'; import { getPackageManagerExecCommand } from 'playwright-core/lib/utils';
import { getEastAsianWidth } from '../utilsBundle';
import type { ReporterV2 } from './reporterV2'; import type { ReporterV2 } from './reporterV2';
import { resolveReporterOutputPath } from '../util'; import { resolveReporterOutputPath } from '../util';
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' }; export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
@ -26,13 +27,6 @@ export const kOutputSymbol = Symbol('output');
type ErrorDetails = { type ErrorDetails = {
message: string; message: string;
location?: Location; location?: Location;
timeout?: number;
matcherName?: string;
locator?: string;
expected?: string;
received?: string;
log?: string[];
snippet?: string;
}; };
type TestSummary = { type TestSummary = {
@ -362,13 +356,6 @@ export function formatResultFailure(test: TestCase, result: TestResult, initialI
errorDetails.push({ errorDetails.push({
message: indent(formattedError.message, initialIndent), message: indent(formattedError.message, initialIndent),
location: formattedError.location, location: formattedError.location,
timeout: error.timeout,
matcherName: error.matcherName,
locator: error.locator,
expected: error.expected,
received: error.received,
log: error.log,
snippet: error.snippet,
}); });
} }
return errorDetails; return errorDetails;
@ -448,15 +435,16 @@ export function formatError(error: TestError, highlightCode: boolean): ErrorDeta
tokens.push(snippet); tokens.push(snippet);
} }
if (parsedStack && parsedStack.stackLines.length) { if (parsedStack && parsedStack.stackLines.length)
tokens.push('');
tokens.push(colors.dim(parsedStack.stackLines.join('\n'))); tokens.push(colors.dim(parsedStack.stackLines.join('\n')));
}
let location = error.location; let location = error.location;
if (parsedStack && !location) if (parsedStack && !location)
location = parsedStack.location; location = parsedStack.location;
if (error.cause)
tokens.push(colors.dim('[cause]: ') + formatError(error.cause, highlightCode).message);
return { return {
location, location,
message: tokens.join('\n'), message: tokens.join('\n'),
@ -503,11 +491,35 @@ export function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, ''); return str.replace(ansiRegex, '');
} }
function characterWidth(c: string) {
return getEastAsianWidth.eastAsianWidth(c.codePointAt(0)!);
}
function stringWidth(v: string) {
let width = 0;
for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(v))
width += characterWidth(segment);
return width;
}
function suffixOfWidth(v: string, width: number) {
const segments = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(v)];
let suffixBegin = v.length;
for (const { segment, index } of segments.reverse()) {
const segmentWidth = stringWidth(segment);
if (segmentWidth > width)
break;
width -= segmentWidth;
suffixBegin = index;
}
return v.substring(suffixBegin);
}
// Leaves enough space for the "prefix" to also fit. // Leaves enough space for the "prefix" to also fit.
function fitToWidth(line: string, width: number, prefix?: string): string { export function fitToWidth(line: string, width: number, prefix?: string): string {
const prefixLength = prefix ? stripAnsiEscapes(prefix).length : 0; const prefixLength = prefix ? stripAnsiEscapes(prefix).length : 0;
width -= prefixLength; width -= prefixLength;
if (line.length <= width) if (stringWidth(line) <= width)
return line; return line;
// Even items are plain text, odd items are control sequences. // Even items are plain text, odd items are control sequences.
@ -518,13 +530,14 @@ function fitToWidth(line: string, width: number, prefix?: string): string {
// Include all control sequences to preserve formatting. // Include all control sequences to preserve formatting.
taken.push(parts[i]); taken.push(parts[i]);
} else { } else {
let part = parts[i].substring(parts[i].length - width); let part = suffixOfWidth(parts[i], width);
if (part.length < parts[i].length && part.length > 0) { const wasTruncated = part.length < parts[i].length;
if (wasTruncated && parts[i].length > 0) {
// Add ellipsis if we are truncating. // Add ellipsis if we are truncating.
part = '\u2026' + part.substring(1); part = '\u2026' + suffixOfWidth(parts[i], width - 1);
} }
taken.push(part); taken.push(part);
width -= part.length; width -= stringWidth(part);
} }
} }
return taken.reverse().join(''); return taken.reverse().join('');

View file

@ -27,6 +27,7 @@ import type { FullConfigInternal } from '../common/config';
import type { ReporterV2 } from '../reporters/reporterV2'; import type { ReporterV2 } from '../reporters/reporterV2';
import type { FailureTracker } from './failureTracker'; import type { FailureTracker } from './failureTracker';
import { colors } from 'playwright-core/lib/utilsBundle'; import { colors } from 'playwright-core/lib/utilsBundle';
import { addSuggestedRebaseline } from './rebase';
export type EnvByProjectId = Map<string, Record<string, string | undefined>>; export type EnvByProjectId = Map<string, Record<string, string | undefined>>;
@ -341,6 +342,8 @@ class JobDispatcher {
step.duration = params.wallTime - step.startTime.getTime(); step.duration = params.wallTime - step.startTime.getTime();
if (params.error) if (params.error)
step.error = params.error; step.error = params.error;
if (params.suggestedRebaseline)
addSuggestedRebaseline(step.location!, params.suggestedRebaseline);
steps.delete(params.stepId); steps.delete(params.stepId);
this._reporter.onStepEnd?.(test, result, step); this._reporter.onStepEnd?.(test, result, step);
} }

View file

@ -30,7 +30,7 @@ import { applyRepeatEachIndex, bindFileSuiteToProject, filterByFocusedLine, filt
import { createTestGroups, filterForShard, type TestGroup } from './testGroups'; import { createTestGroups, filterForShard, type TestGroup } from './testGroups';
import { dependenciesForTestFile } from '../transform/compilationCache'; import { dependenciesForTestFile } from '../transform/compilationCache';
import { sourceMapSupport } from '../utilsBundle'; import { sourceMapSupport } from '../utilsBundle';
import type { RawSourceMap } from 'source-map'; import type { RawSourceMap } from '../utilsBundle';
export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean) { export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean) {

View file

@ -0,0 +1,107 @@
/**
* 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 path from 'path';
import fs from 'fs';
import type { T } from '../transform/babelBundle';
import { types, traverse, babelParse } from '../transform/babelBundle';
import { MultiMap } from 'playwright-core/lib/utils';
import { generateUnifiedDiff } from 'playwright-core/lib/utils';
import { colors } from 'playwright-core/lib/utilsBundle';
import type { FullConfigInternal } from '../common/config';
import { filterProjects } from './projectUtils';
const t: typeof T = types;
type Location = {
file: string;
line: number;
column: number;
};
type Replacement = {
// Points to the call expression.
location: Location;
code: string;
};
const suggestedRebaselines = new MultiMap<string, Replacement>();
export function addSuggestedRebaseline(location: Location, suggestedRebaseline: string) {
suggestedRebaselines.set(location.file, { location, code: suggestedRebaseline });
}
export async function applySuggestedRebaselines(config: FullConfigInternal) {
if (config.config.updateSnapshots !== 'all' && config.config.updateSnapshots !== 'missing')
return;
if (!suggestedRebaselines.size)
return;
const [project] = filterProjects(config.projects, config.cliProjectFilter);
if (!project)
return;
const patches: string[] = [];
const files: string[] = [];
for (const fileName of [...suggestedRebaselines.keys()].sort()) {
const source = await fs.promises.readFile(fileName, 'utf8');
const lines = source.split('\n');
const replacements = suggestedRebaselines.get(fileName);
const fileNode = babelParse(source, fileName, true);
const ranges: { start: number, end: number, oldText: string, newText: string }[] = [];
traverse(fileNode, {
CallExpression: path => {
const node = path.node;
if (node.arguments.length !== 1)
return;
if (!t.isMemberExpression(node.callee))
return;
const argument = node.arguments[0];
if (!t.isStringLiteral(argument) && !t.isTemplateLiteral(argument))
return;
const matcher = node.callee.property;
for (const replacement of replacements) {
// In Babel, rows are 1-based, columns are 0-based.
if (matcher.loc!.start.line !== replacement.location.line)
continue;
if (matcher.loc!.start.column + 1 !== replacement.location.column)
continue;
const indent = lines[matcher.loc!.start.line - 1].match(/^\s*/)![0];
const newText = replacement.code.replace(/\{indent\}/g, indent);
ranges.push({ start: matcher.start!, end: node.end!, oldText: source.substring(matcher.start!, node.end!), newText });
}
}
});
ranges.sort((a, b) => b.start - a.start);
let result = source;
for (const range of ranges)
result = result.substring(0, range.start) + range.newText + result.substring(range.end);
const relativeName = path.relative(process.cwd(), fileName);
files.push(relativeName);
patches.push(generateUnifiedDiff(source, result, relativeName.replace(/\\/g, '/')));
}
const patchFile = path.join(project.project.outputDir, 'rebaselines.patch');
await fs.promises.mkdir(path.dirname(patchFile), { recursive: true });
await fs.promises.writeFile(patchFile, patches.join('\n'));
const fileList = files.map(file => ' ' + colors.dim(file)).join('\n');
// eslint-disable-next-line no-console
console.log(`New baselines created for:\n\n${fileList}\n\n ` + colors.cyan('git apply ' + path.relative(process.cwd(), patchFile)) + '\n');
}

View file

@ -24,6 +24,7 @@ import type { FullConfigInternal } from '../common/config';
import { affectedTestFiles } from '../transform/compilationCache'; import { affectedTestFiles } from '../transform/compilationCache';
import { InternalReporter } from '../reporters/internalReporter'; import { InternalReporter } from '../reporters/internalReporter';
import { LastRunReporter } from './lastRun'; import { LastRunReporter } from './lastRun';
import { applySuggestedRebaselines } from './rebase';
type ProjectConfigWithFiles = { type ProjectConfigWithFiles = {
name: string; name: string;
@ -88,6 +89,8 @@ export class Runner {
]; ];
const status = await runTasks(new TestRun(config, reporter), tasks, config.config.globalTimeout); const status = await runTasks(new TestRun(config, reporter), tasks, config.config.globalTimeout);
await applySuggestedRebaselines(config);
// Calling process.exit() might truncate large stdout/stderr output. // Calling process.exit() might truncate large stdout/stderr output.
// See https://github.com/nodejs/node/issues/6456. // See https://github.com/nodejs/node/issues/6456.
// See https://github.com/nodejs/node/issues/12921 // See https://github.com/nodejs/node/issues/12921

View file

@ -14,14 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
import type { BabelFileResult } from '../../bundles/babel/node_modules/@types/babel__core'; import type { BabelFileResult, ParseResult } from '../../bundles/babel/node_modules/@types/babel__core';
export const codeFrameColumns: typeof import('../../bundles/babel/node_modules/@types/babel__code-frame').codeFrameColumns = require('./babelBundleImpl').codeFrameColumns; export const codeFrameColumns: typeof import('../../bundles/babel/node_modules/@types/babel__code-frame').codeFrameColumns = require('./babelBundleImpl').codeFrameColumns;
export const declare: typeof import('../../bundles/babel/node_modules/@types/babel__helper-plugin-utils').declare = require('./babelBundleImpl').declare; export const declare: typeof import('../../bundles/babel/node_modules/@types/babel__helper-plugin-utils').declare = require('./babelBundleImpl').declare;
export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types; export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types;
export const parse: typeof import('../../bundles/babel/node_modules/@babel/parser/typings/babel-parser').parse = require('./babelBundleImpl').parse;
export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse; export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse;
export type BabelPlugin = [string, any?]; export type BabelPlugin = [string, any?];
export type BabelTransformFunction = (code: string, filename: string, isTypeScript: boolean, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult; export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult;
export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform; export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform;
export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult;
export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse;
export type { NodePath, types as T, PluginObj } from '../../bundles/babel/node_modules/@types/babel__core'; export type { NodePath, types as T, PluginObj } from '../../bundles/babel/node_modules/@types/babel__core';
export type { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils'; export type { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils';

View file

@ -215,7 +215,6 @@ export function setTransformData(pluginName: string, value: any) {
} }
export function transformHook(originalCode: string, filename: string, moduleUrl?: string): { code: string, serializedCache?: any } { export function transformHook(originalCode: string, filename: string, moduleUrl?: string): { code: string, serializedCache?: any } {
const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts');
const hasPreprocessor = const hasPreprocessor =
process.env.PW_TEST_SOURCE_TRANSFORM && process.env.PW_TEST_SOURCE_TRANSFORM &&
process.env.PW_TEST_SOURCE_TRANSFORM_SCOPE && process.env.PW_TEST_SOURCE_TRANSFORM_SCOPE &&
@ -233,7 +232,7 @@ export function transformHook(originalCode: string, filename: string, moduleUrl?
const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle'); const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle');
transformData = new Map<string, any>(); transformData = new Map<string, any>();
const { code, map } = babelTransform(originalCode, filename, isTypeScript, !!moduleUrl, pluginsPrologue, pluginsEpilogue); const { code, map } = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue);
if (!code) if (!code)
return { code: '', serializedCache }; return { code: '', serializedCache };
const added = addToCache!(code, map, transformData); const added = addToCache!(code, map, transformData);

View file

@ -29,15 +29,17 @@ import type { TestInfoErrorImpl } from './common/ipc';
const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..'); const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..');
const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core/package.json')); const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core/package.json'));
export function filterStackTrace(e: Error): { message: string, stack: string } { export function filterStackTrace(e: Error): { message: string, stack: string, cause?: ReturnType<typeof filterStackTrace> } {
const name = e.name ? e.name + ': ' : ''; const name = e.name ? e.name + ': ' : '';
const cause = e.cause instanceof Error ? filterStackTrace(e.cause) : undefined;
if (process.env.PWDEBUGIMPL) if (process.env.PWDEBUGIMPL)
return { message: name + e.message, stack: e.stack || '' }; return { message: name + e.message, stack: e.stack || '', cause };
const stackLines = stringifyStackFrames(filteredStackTrace(e.stack?.split('\n') || [])); const stackLines = stringifyStackFrames(filteredStackTrace(e.stack?.split('\n') || []));
return { return {
message: name + e.message, message: name + e.message,
stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}` stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}`,
cause,
}; };
} }

View file

@ -20,3 +20,5 @@ export const sourceMapSupport: typeof import('../bundles/utils/node_modules/@typ
export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable; export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable;
export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer; export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer;
export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar; export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar;
export const getEastAsianWidth: typeof import('../bundles/utils/node_modules/get-east-asian-width') = require('./utilsBundleImpl').getEastAsianWidth;
export type { RawSourceMap } from '../bundles/utils/node_modules/source-map';

View file

@ -31,7 +31,7 @@ import type { StackFrame } from '@protocol/channels';
import { testInfoError } from './util'; import { testInfoError } from './util';
export interface TestStepInternal { export interface TestStepInternal {
complete(result: { error?: Error | unknown, attachments?: Attachment[] }): void; complete(result: { error?: Error | unknown, attachments?: Attachment[], suggestedRebaseline?: string }): void;
stepId: string; stepId: string;
title: string; title: string;
category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string; category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string;
@ -297,6 +297,7 @@ export class TestInfoImpl implements TestInfo {
stepId, stepId,
wallTime: step.endWallTime, wallTime: step.endWallTime,
error: step.error, error: step.error,
suggestedRebaseline: result.suggestedRebaseline,
}; };
this._onStepEnd(payload); this._onStepEnd(payload);
const errorForTrace = step.error ? { name: '', message: step.error.message || '', stack: step.error.stack } : undefined; const errorForTrace = step.error ? { name: '', message: step.error.message || '', stack: step.error.stack } : undefined;

View file

@ -224,11 +224,18 @@ export class TestTracing {
const stack = rawStack ? filteredStackTrace(rawStack) : []; const stack = rawStack ? filteredStackTrace(rawStack) : [];
this._appendTraceEvent({ this._appendTraceEvent({
type: 'error', type: 'error',
message: error.message || String(error.value), message: this._formatError(error),
stack, stack,
}); });
} }
_formatError(error: TestInfoErrorImpl) {
const parts: string[] = [error.message || String(error.value)];
if (error.cause)
parts.push('[cause]: ' + this._formatError(error.cause));
return parts.join('\n');
}
appendStdioToTrace(type: 'stdout' | 'stderr', chunk: string | Buffer) { appendStdioToTrace(type: 'stdout' | 'stderr', chunk: string | Buffer) {
this._appendTraceEvent({ this._appendTraceEvent({
type, type,

View file

@ -9152,6 +9152,13 @@ export interface TestInfo {
* Information about an error thrown during test execution. * Information about an error thrown during test execution.
*/ */
export interface TestInfoError { export interface TestInfoError {
/**
* Error cause. Set when there is a
* [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the
* error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error].
*/
cause?: TestInfoError;
/** /**
* Error message. Set when [Error] (or its subclass) has been thrown. * Error message. Set when [Error] (or its subclass) has been thrown.
*/ */

View file

@ -555,40 +555,22 @@ export interface TestCase {
*/ */
export interface TestError { export interface TestError {
/** /**
* Expected value formatted as a human-readable string. * Error cause. Set when there is a
* [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the
* error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error].
*/ */
expected?: string; cause?: TestError;
/** /**
* Error location in the source code. * Error location in the source code.
*/ */
location?: Location; location?: Location;
/**
* Receiver's locator.
*/
locator?: string;
/**
* Call log.
*/
log?: Array<string>;
/**
* Expect matcher name.
*/
matcherName?: string;
/** /**
* Error message. Set when [Error] (or its subclass) has been thrown. * Error message. Set when [Error] (or its subclass) has been thrown.
*/ */
message?: string; message?: string;
/**
* Received value formatted as a human-readable string.
*/
received?: string;
/** /**
* Source code snippet with highlighted error. * Source code snippet with highlighted error.
*/ */
@ -599,11 +581,6 @@ export interface TestError {
*/ */
stack?: string; stack?: string;
/**
* Timeout in milliseconds, if the error was caused by a timeout.
*/
timeout?: number;
/** /**
* The value that was thrown. Set when anything except the [Error] (or its subclass) has been thrown. * The value that was thrown. Set when anything except the [Error] (or its subclass) has been thrown.
*/ */

View file

@ -1777,6 +1777,7 @@ export type BrowserContextEnableRecorderParams = {
device?: string, device?: string,
saveStorage?: string, saveStorage?: string,
outputFile?: string, outputFile?: string,
handleSIGINT?: boolean,
omitCallTracking?: boolean, omitCallTracking?: boolean,
}; };
export type BrowserContextEnableRecorderOptions = { export type BrowserContextEnableRecorderOptions = {
@ -1790,6 +1791,7 @@ export type BrowserContextEnableRecorderOptions = {
device?: string, device?: string,
saveStorage?: string, saveStorage?: string,
outputFile?: string, outputFile?: string,
handleSIGINT?: boolean,
omitCallTracking?: boolean, omitCallTracking?: boolean,
}; };
export type BrowserContextEnableRecorderResult = void; export type BrowserContextEnableRecorderResult = void;
@ -2139,7 +2141,7 @@ export type PageReloadResult = {
}; };
export type PageExpectScreenshotParams = { export type PageExpectScreenshotParams = {
expected?: Binary, expected?: Binary,
timeout?: number, timeout: number,
isNot: boolean, isNot: boolean,
locator?: { locator?: {
frame: FrameChannel, frame: FrameChannel,
@ -2164,7 +2166,6 @@ export type PageExpectScreenshotParams = {
}; };
export type PageExpectScreenshotOptions = { export type PageExpectScreenshotOptions = {
expected?: Binary, expected?: Binary,
timeout?: number,
locator?: { locator?: {
frame: FrameChannel, frame: FrameChannel,
selector: string, selector: string,
@ -2191,6 +2192,7 @@ export type PageExpectScreenshotResult = {
errorMessage?: string, errorMessage?: string,
actual?: Binary, actual?: Binary,
previous?: Binary, previous?: Binary,
timedOut?: boolean,
log?: string[], log?: string[],
}; };
export type PageScreenshotParams = { export type PageScreenshotParams = {
@ -3160,7 +3162,7 @@ export type FrameExpectParams = {
expectedValue?: SerializedArgument, expectedValue?: SerializedArgument,
useInnerText?: boolean, useInnerText?: boolean,
isNot: boolean, isNot: boolean,
timeout?: number, timeout: number,
}; };
export type FrameExpectOptions = { export type FrameExpectOptions = {
expressionArg?: any, expressionArg?: any,
@ -3168,7 +3170,6 @@ export type FrameExpectOptions = {
expectedNumber?: number, expectedNumber?: number,
expectedValue?: SerializedArgument, expectedValue?: SerializedArgument,
useInnerText?: boolean, useInnerText?: boolean,
timeout?: number,
}; };
export type FrameExpectResult = { export type FrameExpectResult = {
matches: boolean, matches: boolean,

View file

@ -1208,6 +1208,7 @@ BrowserContext:
device: string? device: string?
saveStorage: string? saveStorage: string?
outputFile: string? outputFile: string?
handleSIGINT: boolean?
omitCallTracking: boolean? omitCallTracking: boolean?
newCDPSession: newCDPSession:
@ -1481,7 +1482,7 @@ Page:
expectScreenshot: expectScreenshot:
parameters: parameters:
expected: binary? expected: binary?
timeout: number? timeout: number
isNot: boolean isNot: boolean
locator: locator:
type: object? type: object?
@ -1500,6 +1501,7 @@ Page:
errorMessage: string? errorMessage: string?
actual: binary? actual: binary?
previous: binary? previous: binary?
timedOut: boolean?
log: log:
type: array? type: array?
items: string items: string
@ -2386,7 +2388,7 @@ Frame:
expectedValue: SerializedArgument? expectedValue: SerializedArgument?
useInnerText: boolean? useInnerText: boolean?
isNot: boolean isNot: boolean
timeout: number? timeout: number
returns: returns:
matches: boolean matches: boolean
received: SerializedValue? received: SerializedValue?

View file

@ -22,5 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
public/sw.bundle.js*

View file

@ -45,7 +45,7 @@ import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader';
if (window.location.protocol !== 'file:') { if (window.location.protocol !== 'file:') {
if (!navigator.serviceWorker) if (!navigator.serviceWorker)
throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`); throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`);
navigator.serviceWorker.register('sw.bundle.js' + window.location.search); navigator.serviceWorker.register('sw.bundle.js');
if (!navigator.serviceWorker.controller) { if (!navigator.serviceWorker.controller) {
await new Promise<void>(f => { await new Promise<void>(f => {
navigator.serviceWorker.oncontrollerchange = () => f(); navigator.serviceWorker.oncontrollerchange = () => f();

View file

@ -27,7 +27,7 @@ import { WorkbenchLoader } from './ui/workbenchLoader';
await new Promise(f => setTimeout(f, 1000)); await new Promise(f => setTimeout(f, 1000));
if (!navigator.serviceWorker) if (!navigator.serviceWorker)
throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`); throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`);
navigator.serviceWorker.register('sw.bundle.js' + window.location.search); navigator.serviceWorker.register('sw.bundle.js');
if (!navigator.serviceWorker.controller) { if (!navigator.serviceWorker.controller) {
await new Promise<void>(f => { await new Promise<void>(f => {
navigator.serviceWorker.oncontrollerchange = () => f(); navigator.serviceWorker.oncontrollerchange = () => f();

View file

@ -26,7 +26,7 @@ import { RecorderView } from './ui/recorder/recorderView';
if (window.location.protocol !== 'file:') { if (window.location.protocol !== 'file:') {
if (!navigator.serviceWorker) if (!navigator.serviceWorker)
throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`); throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`);
navigator.serviceWorker.register('sw.bundle.js' + window.location.search); navigator.serviceWorker.register('sw.bundle.js');
if (!navigator.serviceWorker.controller) { if (!navigator.serviceWorker.controller) {
await new Promise<void>(f => { await new Promise<void>(f => {
navigator.serviceWorker.oncontrollerchange = () => f(); navigator.serviceWorker.oncontrollerchange = () => f();

View file

@ -30,8 +30,9 @@ export class ZipTraceModelBackend implements TraceModelBackend {
constructor(traceURL: string, progress: Progress) { constructor(traceURL: string, progress: Progress) {
this._traceURL = traceURL; this._traceURL = traceURL;
zipjs.configure({ baseURL: self.location.href } as any);
this._zipReader = new zipjs.ZipReader( this._zipReader = new zipjs.ZipReader(
new zipjs.HttpReader(formatTraceFileUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any), new zipjs.HttpReader(formatUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any),
{ useWebWorkers: false }); { useWebWorkers: false });
this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => { this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => {
const map = new Map<string, zip.Entry>(); const map = new Map<string, zip.Entry>();
@ -86,7 +87,7 @@ export class FetchTraceModelBackend implements TraceModelBackend {
constructor(traceURL: string) { constructor(traceURL: string) {
this._traceURL = traceURL; this._traceURL = traceURL;
this._entriesPromise = fetch(formatTraceFileUrl(traceURL)).then(async response => { this._entriesPromise = fetch('/trace/file?path=' + encodeURIComponent(traceURL)).then(async response => {
const json = JSON.parse(await response.text()); const json = JSON.parse(await response.text());
const entries = new Map<string, string>(); const entries = new Map<string, string>();
for (const entry of json.entries) for (const entry of json.entries)
@ -128,22 +129,14 @@ export class FetchTraceModelBackend implements TraceModelBackend {
const fileName = entries.get(entryName); const fileName = entries.get(entryName);
if (!fileName) if (!fileName)
return; return;
return fetch('/trace/file?path=' + encodeURIComponent(fileName));
return fetch(formatTraceFileUrl(fileName));
} }
} }
const baseURL = new URL(self.location.href); function formatUrl(trace: string) {
baseURL.port = baseURL.searchParams.get('testServerPort') ?? baseURL.port; let url = trace.startsWith('http') || trace.startsWith('blob') ? trace : `file?path=${encodeURIComponent(trace)}`;
// Dropbox does not support cors.
function formatTraceFileUrl(trace: string) { if (url.startsWith('https://www.dropbox.com/'))
if (trace.startsWith('https://www.dropbox.com/')) url = 'https://dl.dropboxusercontent.com/' + url.substring('https://www.dropbox.com/'.length);
return 'https://dl.dropboxusercontent.com/' + trace.substring('https://www.dropbox.com/'.length); return url;
if (trace.startsWith('http') || trace.startsWith('blob'))
return trace;
const url = new URL('/trace/file', baseURL);
url.searchParams.set('path', trace);
return url.toString();
} }

Some files were not shown because too many files have changed in this diff Show more