Merge branch 'main' into sk-branch-2
This commit is contained in:
commit
110312cf62
|
|
@ -1,6 +1,6 @@
|
|||
# 🎭 Playwright
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
||||
|
||||
## [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 |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| 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: |
|
||||
| Firefox <!-- GEN:firefox-version -->131.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
|
||||
|
|
|
|||
|
|
@ -104,38 +104,23 @@ await page.Keyboard.PressAsync("Shift+A");
|
|||
An example to trigger select-all with the keyboard
|
||||
|
||||
```js
|
||||
// on Windows and Linux
|
||||
await page.keyboard.press('Control+A');
|
||||
// on macOS
|
||||
await page.keyboard.press('Meta+A');
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
```
|
||||
|
||||
```java
|
||||
// on Windows and Linux
|
||||
page.keyboard().press("Control+A");
|
||||
// on macOS
|
||||
page.keyboard().press("Meta+A");
|
||||
page.keyboard().press("ControlOrMeta+A");
|
||||
```
|
||||
|
||||
```python async
|
||||
# on windows and linux
|
||||
await page.keyboard.press("Control+A")
|
||||
# on mac_os
|
||||
await page.keyboard.press("Meta+A")
|
||||
await page.keyboard.press("ControlOrMeta+A")
|
||||
```
|
||||
|
||||
```python sync
|
||||
# on windows and linux
|
||||
page.keyboard.press("Control+A")
|
||||
# on mac_os
|
||||
page.keyboard.press("Meta+A")
|
||||
page.keyboard.press("ControlOrMeta+A")
|
||||
```
|
||||
|
||||
```csharp
|
||||
// on Windows and Linux
|
||||
await page.Keyboard.PressAsync("Control+A");
|
||||
// on macOS
|
||||
await page.Keyboard.PressAsync("Meta+A");
|
||||
await page.Keyboard.PressAsync("ControlOrMeta+A");
|
||||
```
|
||||
|
||||
## async method: Keyboard.down
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ await page
|
|||
|
||||
#### 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
|
||||
|
|
|
|||
|
|
@ -401,6 +401,23 @@ pytest test_login.py --browser-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
|
||||
|
||||
If Google Chrome or Microsoft Edge is not available on your machine, you can install
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
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
|
||||
* since: v1.10
|
||||
- type: ?<[string]>
|
||||
|
|
|
|||
|
|
@ -521,7 +521,6 @@ You can use `beforeMount` and `afterMount` hooks to configure your app. This let
|
|||
{label: 'React', value: 'react'},
|
||||
{label: 'Solid', value: 'solid'},
|
||||
{label: 'Vue3', value: 'vue3'},
|
||||
{label: 'Vue2', value: 'vue2'},
|
||||
]
|
||||
}>
|
||||
<TabItem value="react">
|
||||
|
|
@ -617,40 +616,6 @@ You can use `beforeMount` and `afterMount` hooks to configure your app. This let
|
|||
|
||||
</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>
|
||||
|
||||
### unmount
|
||||
|
|
|
|||
|
|
@ -4,29 +4,11 @@
|
|||
|
||||
Information about an error thrown during test execution.
|
||||
|
||||
## property: TestError.expected
|
||||
## property: TestError.cause
|
||||
* since: v1.49
|
||||
- type: ?<[string]>
|
||||
- type: ?<[TestError]>
|
||||
|
||||
Expected value formatted as a human-readable string.
|
||||
|
||||
## 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.
|
||||
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.message
|
||||
* since: v1.10
|
||||
|
|
@ -34,24 +16,12 @@ Expect matcher name.
|
|||
|
||||
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
|
||||
* since: v1.10
|
||||
- type: ?<[string]>
|
||||
|
||||
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
|
||||
* since: v1.10
|
||||
- type: ?<[string]>
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ See [Running Tests](./running-tests.md) for general information on `pytest` opti
|
|||
|
||||
## Examples
|
||||
|
||||
### Configure Mypy typings for auto-completion
|
||||
### Configure typings for auto-completion
|
||||
|
||||
```py title="test_my_application.py"
|
||||
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
|
||||
pytest --slowmo 100
|
||||
In order to simulate multiple users, you can create multiple [`BrowserContext`](./browser-contexts) instances.
|
||||
|
||||
```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
|
||||
|
||||
```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"
|
||||
import pytest
|
||||
|
|
|
|||
107
package-lock.json
generated
107
package-lock.json
generated
|
|
@ -1508,10 +1508,6 @@
|
|||
"resolved": "packages/playwright-ct-vue",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@playwright/experimental-ct-vue2": {
|
||||
"resolved": "packages/playwright-ct-vue2",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"resolved": "packages/playwright-test",
|
||||
"link": true
|
||||
|
|
@ -2416,28 +2412,6 @@
|
|||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
|
|
@ -5964,21 +5938,6 @@
|
|||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
|
|
@ -6593,14 +6552,6 @@
|
|||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -7922,10 +7873,7 @@
|
|||
}
|
||||
},
|
||||
"packages/html-reporter": {
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"ansi-to-html": "^0.7.2"
|
||||
}
|
||||
"version": "0.0.0"
|
||||
},
|
||||
"packages/playwright": {
|
||||
"version": "1.49.0-next",
|
||||
|
|
@ -8097,59 +8045,6 @@
|
|||
"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": {
|
||||
"version": "1.49.0-next",
|
||||
"hasInstallScript": true,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,5 @@
|
|||
"dev": "vite",
|
||||
"build": "vite build && tsc",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-to-html": "^0.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,3 +48,14 @@ test('setExpanded is called', async ({ mount }) => {
|
|||
await component.getByText('Title').click();
|
||||
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
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,8 +30,12 @@ export const Chip: React.FC<{
|
|||
dataTestId?: string,
|
||||
targetRef?: React.RefObject<HTMLDivElement>,
|
||||
}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => {
|
||||
const id = React.useId();
|
||||
return <div className='chip' data-testid={dataTestId} ref={targetRef}>
|
||||
<div
|
||||
role='button'
|
||||
aria-expanded={!!expanded}
|
||||
aria-controls={id}
|
||||
className={clsx('chip-header', setExpanded && ' expanded-' + expanded)}
|
||||
onClick={() => setExpanded?.(!expanded)}
|
||||
title={typeof header === 'string' ? header : undefined}>
|
||||
|
|
@ -39,7 +43,7 @@ export const Chip: React.FC<{
|
|||
{setExpanded && !expanded && icons.rightArrow()}
|
||||
{header}
|
||||
</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>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 'Flaky' }).locator('.counter')).toHaveText('17');
|
||||
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 }) => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import './colors.css';
|
|||
import './common.css';
|
||||
import './headerView.css';
|
||||
import * as icons from './icons';
|
||||
import { Link, navigate } from './links';
|
||||
import { Link, navigate, SearchParamsContext } from './links';
|
||||
import { statusIcon } from './statusIcon';
|
||||
import { filterWithToken } from './filter';
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
|
|||
const StatsNavView: React.FC<{
|
||||
stats: Stats
|
||||
}> = ({ stats }) => {
|
||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const searchParams = React.useContext(SearchParamsContext);
|
||||
const q = searchParams.get('q')?.toString() || '';
|
||||
const tokens = q.split(' ');
|
||||
return <nav>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { ReportView } from './reportView';
|
|||
const zipjs = zipImport as typeof zip;
|
||||
|
||||
import logo from '@web/assets/playwright-logo.svg';
|
||||
import { SearchParamsProvider } from './links';
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'shortcut icon';
|
||||
link.href = logo;
|
||||
|
|
@ -40,7 +41,9 @@ const ReportLoader: React.FC = () => {
|
|||
const zipReport = new ZipReport();
|
||||
zipReport.load().then(() => setReport(zipReport));
|
||||
}, [report]);
|
||||
return <ReportView report={report}></ReportView>;
|
||||
return <SearchParamsProvider>
|
||||
<ReportView report={report} />
|
||||
</SearchParamsProvider>;
|
||||
};
|
||||
|
||||
window.onload = () => {
|
||||
|
|
|
|||
|
|
@ -33,13 +33,8 @@ export const Route: React.FunctionComponent<{
|
|||
predicate: (params: URLSearchParams) => boolean,
|
||||
children: any
|
||||
}> = ({ predicate, children }) => {
|
||||
const [matches, setMatches] = React.useState(predicate(new URLSearchParams(window.location.hash.slice(1))));
|
||||
React.useEffect(() => {
|
||||
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;
|
||||
const searchParams = React.useContext(SearchParamsContext);
|
||||
return predicate(searchParams) ? children : null;
|
||||
};
|
||||
|
||||
export const Link: React.FunctionComponent<{
|
||||
|
|
@ -90,6 +85,20 @@ export const AttachmentLink: React.FunctionComponent<{
|
|||
} : 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 {
|
||||
if (attachment.name.includes('.') || !attachment.path)
|
||||
return attachment.name;
|
||||
|
|
|
|||
|
|
@ -14,19 +14,19 @@
|
|||
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 './colors.css';
|
||||
import './common.css';
|
||||
import { Filter } from './filter';
|
||||
import { HeaderView } from './headerView';
|
||||
import { Route } from './links';
|
||||
import { Route, SearchParamsContext } from './links';
|
||||
import type { LoadedReport } from './loadedReport';
|
||||
import './reportView.css';
|
||||
import type { Metainfo } from './metadataView';
|
||||
import { MetadataView } from './metadataView';
|
||||
import { TestCaseView } from './testCaseView';
|
||||
import { TestFilesView } from './testFilesView';
|
||||
import { TestFilesHeader, TestFilesView } from './testFilesView';
|
||||
import './theme.css';
|
||||
|
||||
declare global {
|
||||
|
|
@ -39,32 +39,55 @@ declare global {
|
|||
const testFilesRoutePredicate = (params: URLSearchParams) => !params.has('testId');
|
||||
const testCaseRoutePredicate = (params: URLSearchParams) => params.has('testId');
|
||||
|
||||
type TestModelSummary = {
|
||||
files: TestFileSummary[];
|
||||
tests: TestCaseSummary[];
|
||||
};
|
||||
|
||||
export const ReportView: React.FC<{
|
||||
report: LoadedReport | undefined,
|
||||
}> = ({ 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 [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 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'>
|
||||
<main>
|
||||
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
||||
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
|
||||
<Route predicate={testFilesRoutePredicate}>
|
||||
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} />
|
||||
<TestFilesView
|
||||
report={report?.json()}
|
||||
filter={filter}
|
||||
tests={filteredTests.files}
|
||||
expandedFiles={expandedFiles}
|
||||
setExpandedFiles={setExpandedFiles}
|
||||
projectNames={report?.json().projectNames || []}
|
||||
filteredStats={filteredStats}
|
||||
/>
|
||||
</Route>
|
||||
<Route predicate={testCaseRoutePredicate}>
|
||||
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
|
||||
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
|
||||
</Route>
|
||||
</main>
|
||||
</div>;
|
||||
|
|
@ -72,21 +95,21 @@ export const ReportView: React.FC<{
|
|||
|
||||
const TestCaseViewLoader: React.FC<{
|
||||
report: LoadedReport,
|
||||
}> = ({ report }) => {
|
||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
tests: TestCaseSummary[],
|
||||
testIdToFileIdMap: Map<string, string>,
|
||||
}> = ({ report, testIdToFileIdMap, tests }) => {
|
||||
const searchParams = React.useContext(SearchParamsContext);
|
||||
const [test, setTest] = React.useState<TestCase | undefined>();
|
||||
const testId = searchParams.get('testId');
|
||||
const anchor = (searchParams.get('anchor') || '') as 'video' | 'diff' | '';
|
||||
const run = +(searchParams.get('run') || '0');
|
||||
|
||||
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 { prev, next } = React.useMemo(() => {
|
||||
const index = tests.findIndex(t => t.testId === testId);
|
||||
const prev = index > 0 ? tests[index - 1] : undefined;
|
||||
const next = index < tests.length - 1 ? tests[index + 1] : undefined;
|
||||
return { prev, next };
|
||||
}, [testId, tests]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
|
|
@ -104,7 +127,15 @@ const TestCaseViewLoader: React.FC<{
|
|||
}
|
||||
})();
|
||||
}, [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 {
|
||||
|
|
@ -119,4 +150,4 @@ function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
|
|||
stats.duration += test.duration;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
.test-case-column {
|
||||
border-radius: 6px;
|
||||
margin: 24px 0;
|
||||
margin: 12px 0 24px 0;
|
||||
}
|
||||
|
||||
.test-case-column .tab-element.selected {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
import { test, expect } from '@playwright/experimental-ct-react';
|
||||
import { TestCaseView } from './testCaseView';
|
||||
import type { TestCase, TestResult } from './types';
|
||||
import type { TestCase, TestCaseSummary, TestResult } from './types';
|
||||
|
||||
test.use({ viewport: { width: 800, height: 600 } });
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ const testCase: TestCase = {
|
|||
};
|
||||
|
||||
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('Hidden annotation')).toBeHidden();
|
||||
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 }) => {
|
||||
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 component.getByText('Annotation text', { exact: false }).first().hover();
|
||||
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 }) => {
|
||||
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();
|
||||
await expect(firstLink).toBeVisible();
|
||||
|
|
@ -165,18 +165,49 @@ const attachmentLinkRenderingTestCase: TestCase = {
|
|||
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 }) => {
|
||||
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();
|
||||
const body = await component.getByText('The body with https://playwright.dev/docs/intro link');
|
||||
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: '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 }) => {
|
||||
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');
|
||||
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(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"
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { TestCase, TestCaseAnnotation } from './types';
|
||||
import type { TestCase, TestCaseAnnotation, TestCaseSummary } from './types';
|
||||
import * as React from 'react';
|
||||
import { TabbedPane } from './tabbedPane';
|
||||
import { AutoChip } from './chip';
|
||||
import './common.css';
|
||||
import { ProjectLink } from './links';
|
||||
import { Link, ProjectLink, SearchParamsContext } from './links';
|
||||
import { statusIcon } from './statusIcon';
|
||||
import './testCaseView.css';
|
||||
import { TestResultView } from './testResultView';
|
||||
|
|
@ -31,10 +31,14 @@ import { CopyToClipboardContainer } from './copyToClipboard';
|
|||
export const TestCaseView: React.FC<{
|
||||
projectNames: string[],
|
||||
test: TestCase | undefined,
|
||||
next: TestCaseSummary | undefined,
|
||||
prev: TestCaseSummary | undefined,
|
||||
anchor: 'video' | 'diff' | '',
|
||||
run: number,
|
||||
}> = ({ projectNames, test, run, anchor }) => {
|
||||
}> = ({ projectNames, test, run, anchor, next, prev }) => {
|
||||
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
|
||||
const searchParams = React.useContext(SearchParamsContext);
|
||||
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
|
||||
|
||||
const labels = React.useMemo(() => {
|
||||
if (!test)
|
||||
|
|
@ -47,6 +51,11 @@ export const TestCaseView: React.FC<{
|
|||
}, [test?.annotations]);
|
||||
|
||||
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-title'>{test?.title}</div>}
|
||||
{test && <div className='hbox'>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
@import '@web/third_party/vscode/colors.css';
|
||||
|
||||
.test-error-view {
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import ansi2html from 'ansi-to-html';
|
||||
import { ansi2html } from '@web/ansi2html';
|
||||
import * as React from 'react';
|
||||
import './testErrorView.css';
|
||||
import type { ImageDiff } from '@web/shared/imageDiffView';
|
||||
|
|
@ -43,33 +43,9 @@ export const TestScreenshotErrorView: React.FC<{
|
|||
};
|
||||
|
||||
function ansiErrorToHtml(text?: string): string {
|
||||
const config: any = {
|
||||
const defaultColors = {
|
||||
bg: 'var(--color-canvas-subtle)',
|
||||
fg: 'var(--color-fg-default)',
|
||||
};
|
||||
config.colors = ansiColors;
|
||||
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 => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!));
|
||||
return ansi2html(text || '', defaultColors);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,24 +14,25 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { HTMLReport, TestCaseSummary, TestFileSummary } from './types';
|
||||
import type { TestCaseSummary, TestFileSummary } from './types';
|
||||
import * as React from 'react';
|
||||
import { hashStringToInt, msToString } from './utils';
|
||||
import { Chip } from './chip';
|
||||
import { filterWithToken, type Filter } from './filter';
|
||||
import { generateTraceUrl, Link, navigate, ProjectLink } from './links';
|
||||
import { filterWithToken } from './filter';
|
||||
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links';
|
||||
import { statusIcon } from './statusIcon';
|
||||
import './testFileView.css';
|
||||
import { video, image, trace } from './icons';
|
||||
import { clsx } from '@web/uiUtils';
|
||||
|
||||
export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||
report: HTMLReport;
|
||||
file: TestFileSummary;
|
||||
projectNames: string[];
|
||||
isFileExpanded: (fileId: string) => boolean;
|
||||
setFileExpanded: (fileId: string, expanded: boolean) => void;
|
||||
filter: Filter;
|
||||
}>> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => {
|
||||
}>> = ({ file, projectNames, isFileExpanded, setFileExpanded }) => {
|
||||
const searchParams = React.useContext(SearchParamsContext);
|
||||
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
|
||||
return <Chip
|
||||
expanded={isFileExpanded(file.fileId)}
|
||||
noInsets={true}
|
||||
|
|
@ -39,7 +40,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
|||
header={<span>
|
||||
{file.fileName}
|
||||
</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 className='hbox' style={{ alignItems: 'flex-start' }}>
|
||||
<div className='hbox'>
|
||||
|
|
@ -47,11 +48,11 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
|||
{statusIcon(test.outcome)}
|
||||
</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>
|
||||
</Link>
|
||||
{report.projectNames.length > 1 && !!test.projectName &&
|
||||
<ProjectLink projectNames={report.projectNames} projectName={test.projectName} />}
|
||||
{projectNames.length > 1 && !!test.projectName &&
|
||||
<ProjectLink projectNames={projectNames} projectName={test.projectName} />}
|
||||
<LabelsClickView labels={test.tags} />
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -90,10 +91,10 @@ function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
|||
const LabelsClickView: React.FC<React.PropsWithChildren<{
|
||||
labels: string[],
|
||||
}>> = ({ labels }) => {
|
||||
const searchParams = React.useContext(SearchParamsContext);
|
||||
|
||||
const onClickHandle = (e: React.MouseEvent, label: string) => {
|
||||
e.preventDefault();
|
||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const q = searchParams.get('q')?.toString() || '';
|
||||
const tokens = q.split(' ');
|
||||
navigate(filterWithToken(tokens, label, e.metaKey || e.ctrlKey));
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
import type { FilteredStats, HTMLReport, TestFileSummary } from './types';
|
||||
import * as React from 'react';
|
||||
import type { Filter } from './filter';
|
||||
import { TestFileView } from './testFileView';
|
||||
import './testFileView.css';
|
||||
import { msToString } from './utils';
|
||||
|
|
@ -24,40 +23,26 @@ import { AutoChip } from './chip';
|
|||
import { TestErrorView } from './testErrorView';
|
||||
|
||||
export const TestFilesView: React.FC<{
|
||||
report?: HTMLReport,
|
||||
tests: TestFileSummary[],
|
||||
expandedFiles: Map<string, boolean>,
|
||||
setExpandedFiles: (value: Map<string, boolean>) => void,
|
||||
filter: Filter,
|
||||
filteredStats: FilteredStats,
|
||||
projectNames: string[],
|
||||
}> = ({ report, filter, expandedFiles, setExpandedFiles, projectNames, filteredStats }) => {
|
||||
}> = ({ tests, expandedFiles, setExpandedFiles, projectNames }) => {
|
||||
const filteredFiles = React.useMemo(() => {
|
||||
const result: { file: TestFileSummary, defaultExpanded: boolean }[] = [];
|
||||
let visibleTests = 0;
|
||||
for (const file of report?.files || []) {
|
||||
const tests = file.tests.filter(t => filter.matches(t));
|
||||
visibleTests += tests.length;
|
||||
if (tests.length)
|
||||
result.push({ file, defaultExpanded: visibleTests < 200 });
|
||||
for (const file of tests) {
|
||||
visibleTests += file.tests.length;
|
||||
result.push({ file, defaultExpanded: visibleTests < 200 });
|
||||
}
|
||||
return result;
|
||||
}, [report, filter]);
|
||||
}, [tests]);
|
||||
return <>
|
||||
<div className='mt-2 mx-1' style={{ display: 'flex' }}>
|
||||
{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 }) => {
|
||||
{filteredFiles.map(({ file, defaultExpanded }) => {
|
||||
return <TestFileView
|
||||
key={`file-${file.fileId}`}
|
||||
report={report}
|
||||
file={file}
|
||||
projectNames={projectNames}
|
||||
isFileExpanded={fileId => {
|
||||
const value = expandedFiles.get(fileId);
|
||||
if (value === undefined)
|
||||
|
|
@ -68,9 +53,28 @@ export const TestFilesView: React.FC<{
|
|||
const newExpanded = new Map(expandedFiles);
|
||||
newExpanded.set(fileId, expanded);
|
||||
setExpandedFiles(newExpanded);
|
||||
}}
|
||||
filter={filter}>
|
||||
}}>
|
||||
</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>}
|
||||
</>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -152,7 +152,8 @@ export const TestResultView: React.FC<{
|
|||
|
||||
function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
|
||||
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 attachmentName = diff.actual?.attachment.name;
|
||||
return attachmentName && error.includes(attachmentName);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
"browsers": [
|
||||
{
|
||||
"name": "chromium",
|
||||
"revision": "1143",
|
||||
"revision": "1146",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "131.0.6778.3"
|
||||
"browserVersion": "131.0.6778.24"
|
||||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
{
|
||||
"name": "webkit",
|
||||
"revision": "2095",
|
||||
"revision": "2102",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"mac10.14": "1446",
|
||||
|
|
|
|||
|
|
@ -554,6 +554,7 @@ async function open(options: Options, url: string | undefined, language: string)
|
|||
contextOptions,
|
||||
device: options.device,
|
||||
saveStorage: options.saveStorage,
|
||||
handleSIGINT: false,
|
||||
});
|
||||
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',
|
||||
testIdAttributeName,
|
||||
outputFile: outputFile ? path.resolve(outputFile) : undefined,
|
||||
handleSIGINT: false,
|
||||
});
|
||||
await openPage(context, url);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import * as util from 'util';
|
|||
import { asLocator, isString, monotonicTime } from '../utils';
|
||||
import { ElementHandle } from './elementHandle';
|
||||
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 { escapeForTextSelector } from '../utils/isomorphic/stringUtils';
|
||||
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 });
|
||||
}
|
||||
|
||||
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 };
|
||||
params.expectedValue = serializeArgument(options.expectedValue);
|
||||
const result = (await this._frame._channel.expect(params));
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ type PDFOptions = Omit<channels.PagePdfParams, 'width' | 'height' | 'margin'> &
|
|||
export type ExpectScreenshotOptions = Omit<channels.PageExpectScreenshotOptions, 'locator' | 'expected' | 'mask'> & {
|
||||
expected?: Buffer,
|
||||
locator?: api.Locator,
|
||||
timeout: number,
|
||||
isNot: boolean,
|
||||
mask?: api.Locator[],
|
||||
};
|
||||
|
|
@ -589,7 +590,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
|
|||
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 => ({
|
||||
frame: (locator as Locator)._frame._channel,
|
||||
selector: (locator as Locator)._selector,
|
||||
|
|
|
|||
|
|
@ -154,4 +154,4 @@ export type SelectorEngine = {
|
|||
export type RemoteAddr = channels.RemoteAddr;
|
||||
export type SecurityDetails = channels.SecurityDetails;
|
||||
|
||||
export type FrameExpectOptions = channels.FrameExpectOptions & { isNot?: boolean };
|
||||
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'selector'|'expression'|'expectedValue'> & { expectedValue?: any };
|
||||
|
|
|
|||
|
|
@ -976,6 +976,7 @@ scheme.BrowserContextEnableRecorderParams = tObject({
|
|||
device: tOptional(tString),
|
||||
saveStorage: tOptional(tString),
|
||||
outputFile: tOptional(tString),
|
||||
handleSIGINT: tOptional(tBoolean),
|
||||
omitCallTracking: tOptional(tBoolean),
|
||||
});
|
||||
scheme.BrowserContextEnableRecorderResult = tOptional(tObject({}));
|
||||
|
|
@ -1164,7 +1165,7 @@ scheme.PageReloadResult = tObject({
|
|||
});
|
||||
scheme.PageExpectScreenshotParams = tObject({
|
||||
expected: tOptional(tBinary),
|
||||
timeout: tOptional(tNumber),
|
||||
timeout: tNumber,
|
||||
isNot: tBoolean,
|
||||
locator: tOptional(tObject({
|
||||
frame: tChannel(['Frame']),
|
||||
|
|
@ -1192,6 +1193,7 @@ scheme.PageExpectScreenshotResult = tObject({
|
|||
errorMessage: tOptional(tString),
|
||||
actual: tOptional(tBinary),
|
||||
previous: tOptional(tBinary),
|
||||
timedOut: tOptional(tBoolean),
|
||||
log: tOptional(tArray(tString)),
|
||||
});
|
||||
scheme.PageScreenshotParams = tObject({
|
||||
|
|
@ -1768,7 +1770,7 @@ scheme.FrameExpectParams = tObject({
|
|||
expectedValue: tOptional(tType('SerializedArgument')),
|
||||
useInnerText: tOptional(tBoolean),
|
||||
isNot: tBoolean,
|
||||
timeout: tOptional(tNumber),
|
||||
timeout: tNumber,
|
||||
});
|
||||
scheme.FrameExpectResult = tObject({
|
||||
matches: tBoolean,
|
||||
|
|
|
|||
|
|
@ -14,39 +14,54 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { AriaTemplateNode } from './injected/ariaSnapshot';
|
||||
import type { AriaTemplateNode, AriaTemplateRoleNode } from './injected/ariaSnapshot';
|
||||
import { yaml } from '../utilsBundle';
|
||||
import type { AriaRole } from '@injected/roleUtils';
|
||||
import { assert } from '../utils';
|
||||
|
||||
export function parseAriaSnapshot(text: string): AriaTemplateNode {
|
||||
const fragment = yaml.parse(text) as any[];
|
||||
const result: AriaTemplateNode = { role: 'fragment' };
|
||||
const result: AriaTemplateNode = { kind: 'role', role: 'fragment' };
|
||||
populateNode(result, fragment);
|
||||
return result;
|
||||
}
|
||||
|
||||
function populateNode(node: AriaTemplateNode, container: any[]) {
|
||||
function populateNode(node: AriaTemplateRoleNode, container: any[]) {
|
||||
for (const object of container) {
|
||||
if (typeof object === 'string') {
|
||||
const childNode = parseKey(object);
|
||||
const childNode = KeyParser.parse(object);
|
||||
node.children = node.children || [];
|
||||
node.children.push(childNode);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(object)) {
|
||||
const childNode = parseKey(key);
|
||||
const value = object[key];
|
||||
node.children = node.children || [];
|
||||
const value = object[key];
|
||||
|
||||
if (childNode.role === 'text') {
|
||||
node.children.push(valueOrRegex(value));
|
||||
if (key === 'text') {
|
||||
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;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
node.children.push({ ...childNode, children: [valueOrRegex(value)] });
|
||||
node.children.push({
|
||||
...childNode, children: [{
|
||||
kind: 'text',
|
||||
text: valueOrRegex(value)
|
||||
}]
|
||||
});
|
||||
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') {
|
||||
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';
|
||||
|
|
@ -90,47 +105,6 @@ function applyAttribute(node: AriaTemplateNode, key: string, value: string) {
|
|||
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) {
|
||||
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
|
||||
}
|
||||
|
|
@ -138,3 +112,148 @@ function normalizeWhitespace(text: string) {
|
|||
function valueOrRegex(value: string): string | RegExp {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,6 +294,8 @@ export class Chromium extends BrowserType {
|
|||
throw new Error('Playwright manages remote debugging connection itself.');
|
||||
if (args.find(arg => !arg.startsWith('-')))
|
||||
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];
|
||||
|
||||
if (os.platform() === 'darwin') {
|
||||
|
|
|
|||
|
|
@ -275,10 +275,13 @@ ${body}
|
|||
}
|
||||
|
||||
export function quoteMultiline(text: string, indent = ' ') {
|
||||
const escape = (text: string) => text.replace(/\\/g, '\\\\')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$\{/g, '\\${');
|
||||
const lines = text.split('\n');
|
||||
if (lines.length === 1)
|
||||
return '`' + text.replace(/`/g, '\\`').replace(/\${/g, '\\${') + '`';
|
||||
return '`\n' + lines.map(line => indent + line.replace(/`/g, '\\`').replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
|
||||
return '`' + escape(text) + '`';
|
||||
return '`\n' + lines.map(line => indent + escape(line).replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
|
||||
}
|
||||
|
||||
function isMultilineString(text: string) {
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@
|
|||
"defaultBrowserType": "webkit"
|
||||
},
|
||||
"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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 360,
|
||||
"height": 740
|
||||
|
|
@ -143,7 +143,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 740,
|
||||
"height": 360
|
||||
|
|
@ -154,7 +154,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 320,
|
||||
"height": 658
|
||||
|
|
@ -165,7 +165,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 658,
|
||||
"height": 320
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 712,
|
||||
"height": 1138
|
||||
|
|
@ -187,7 +187,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 1138,
|
||||
"height": 712
|
||||
|
|
@ -1098,7 +1098,7 @@
|
|||
"defaultBrowserType": "webkit"
|
||||
},
|
||||
"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": {
|
||||
"width": 384,
|
||||
"height": 640
|
||||
|
|
@ -1109,7 +1109,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 640,
|
||||
"height": 384
|
||||
|
|
@ -1120,7 +1120,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -1131,7 +1131,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -1142,7 +1142,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -1153,7 +1153,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -1164,7 +1164,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 800,
|
||||
"height": 1280
|
||||
|
|
@ -1175,7 +1175,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
|
|
@ -1186,7 +1186,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 384,
|
||||
"height": 640
|
||||
|
|
@ -1197,7 +1197,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 640,
|
||||
"height": 384
|
||||
|
|
@ -1208,7 +1208,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -1219,7 +1219,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -1230,7 +1230,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 412,
|
||||
"height": 732
|
||||
|
|
@ -1241,7 +1241,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 732,
|
||||
"height": 412
|
||||
|
|
@ -1252,7 +1252,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 412,
|
||||
"height": 732
|
||||
|
|
@ -1263,7 +1263,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 732,
|
||||
"height": 412
|
||||
|
|
@ -1274,7 +1274,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 412,
|
||||
"height": 732
|
||||
|
|
@ -1285,7 +1285,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 732,
|
||||
"height": 412
|
||||
|
|
@ -1296,7 +1296,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 600,
|
||||
"height": 960
|
||||
|
|
@ -1307,7 +1307,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 960,
|
||||
"height": 600
|
||||
|
|
@ -1362,7 +1362,7 @@
|
|||
"defaultBrowserType": "webkit"
|
||||
},
|
||||
"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": {
|
||||
"width": 411,
|
||||
"height": 731
|
||||
|
|
@ -1373,7 +1373,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 731,
|
||||
"height": 411
|
||||
|
|
@ -1384,7 +1384,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 411,
|
||||
"height": 823
|
||||
|
|
@ -1395,7 +1395,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 823,
|
||||
"height": 411
|
||||
|
|
@ -1406,7 +1406,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 393,
|
||||
"height": 786
|
||||
|
|
@ -1417,7 +1417,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 786,
|
||||
"height": 393
|
||||
|
|
@ -1428,7 +1428,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 353,
|
||||
"height": 745
|
||||
|
|
@ -1439,7 +1439,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 745,
|
||||
"height": 353
|
||||
|
|
@ -1450,7 +1450,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 412,
|
||||
"height": 892
|
||||
|
|
@ -1465,7 +1465,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"height": 892,
|
||||
"width": 412
|
||||
|
|
@ -1480,7 +1480,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 393,
|
||||
"height": 851
|
||||
|
|
@ -1495,7 +1495,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 851,
|
||||
"height": 393
|
||||
|
|
@ -1510,7 +1510,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 412,
|
||||
"height": 915
|
||||
|
|
@ -1525,7 +1525,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 915,
|
||||
"height": 412
|
||||
|
|
@ -1540,7 +1540,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -1551,7 +1551,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -1562,7 +1562,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 1792,
|
||||
"height": 1120
|
||||
|
|
@ -1577,7 +1577,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 1792,
|
||||
"height": 1120
|
||||
|
|
@ -1622,7 +1622,7 @@
|
|||
"defaultBrowserType": "webkit"
|
||||
},
|
||||
"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": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
|
|
@ -1637,7 +1637,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import type * as channels from '@protocol/channels';
|
||||
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 type { CallMetadata } from '../instrumentation';
|
||||
import { SdkObject } from '../instrumentation';
|
||||
|
|
@ -357,7 +357,7 @@ export class DispatcherConnection {
|
|||
}
|
||||
|
||||
if (response.error)
|
||||
response.log = callMetadata.log;
|
||||
response.log = compressCallLog(callMetadata.log);
|
||||
this.onmessage(response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
|
||||
while (progress.isRunning()) {
|
||||
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)];
|
||||
if (timeout) {
|
||||
progress.log(` waiting ${timeout}ms`);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import * as types from './types';
|
|||
import { BrowserContext } from './browserContext';
|
||||
import type { Progress } 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 { debugLogger } from '../utils/debugLogger';
|
||||
import type { CallMetadata } from './instrumentation';
|
||||
|
|
@ -1458,7 +1458,7 @@ export class Frame extends SdkObject {
|
|||
timeout -= elapsed;
|
||||
}
|
||||
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.
|
||||
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.
|
||||
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(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)
|
||||
result.received = lastIntermediateResult.received;
|
||||
if (e instanceof TimeoutError)
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { escapeWithQuotes } from '@isomorphic/stringUtils';
|
||||
import * as roleUtils from './roleUtils';
|
||||
import { getElementComputedStyle } from './domUtils';
|
||||
import type { AriaRole } from './roleUtils';
|
||||
import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils';
|
||||
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded, yamlQuoteFragment } from './yaml';
|
||||
|
||||
type AriaProps = {
|
||||
checked?: boolean | 'mixed';
|
||||
|
|
@ -34,14 +35,27 @@ type AriaNode = AriaProps & {
|
|||
children: (AriaNode | string)[];
|
||||
};
|
||||
|
||||
export type AriaTemplateNode = AriaProps & {
|
||||
role: AriaRole | 'fragment' | 'text';
|
||||
name?: RegExp | string;
|
||||
children?: (AriaTemplateNode | string | RegExp)[];
|
||||
export type AriaTemplateTextNode = {
|
||||
kind: 'text';
|
||||
text: RegExp | string;
|
||||
};
|
||||
|
||||
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 {
|
||||
const visited = new Set<Node>();
|
||||
const visit = (ariaNode: AriaNode, node: Node) => {
|
||||
if (visited.has(node))
|
||||
return;
|
||||
visited.add(node);
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
|
||||
const text = node.nodeValue;
|
||||
if (text)
|
||||
|
|
@ -56,13 +70,23 @@ export function generateAriaTree(rootElement: Element): AriaNode {
|
|||
if (roleUtils.isElementHiddenForAria(element))
|
||||
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);
|
||||
if (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.
|
||||
const display = getElementComputedStyle(element)?.display || 'inline';
|
||||
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'));
|
||||
|
||||
if (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 = [];
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +135,7 @@ export function generateAriaTree(rootElement: Element): AriaNode {
|
|||
|
||||
function toAriaNode(element: Element): AriaNode | null {
|
||||
const role = roleUtils.getAriaRole(element);
|
||||
if (!role)
|
||||
if (!role || role === 'presentation' || role === 'none')
|
||||
return null;
|
||||
|
||||
const name = roleUtils.getElementAccessibleName(element, false) || '';
|
||||
|
|
@ -132,11 +159,14 @@ function toAriaNode(element: Element): AriaNode | null {
|
|||
if (roleUtils.kAriaSelectedRoles.includes(role))
|
||||
result.selected = roleUtils.getAriaSelected(element);
|
||||
|
||||
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)
|
||||
result.children = [element.value];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderedAriaTree(rootElement: Element): string {
|
||||
return renderAriaTree(generateAriaTree(rootElement));
|
||||
export function renderedAriaTree(rootElement: Element, options?: { mode?: 'raw' | 'regex' }): string {
|
||||
return renderAriaTree(generateAriaTree(rootElement), options);
|
||||
}
|
||||
|
||||
function normalizeStringChildren(rootA11yNode: AriaNode) {
|
||||
|
|
@ -169,9 +199,9 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
|
|||
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)
|
||||
return true;
|
||||
if (!text)
|
||||
|
|
@ -181,17 +211,36 @@ function matchesText(text: string | undefined, template: RegExp | string | undef
|
|||
return !!text.match(template);
|
||||
}
|
||||
|
||||
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
|
||||
const root = generateAriaTree(rootElement);
|
||||
const matches = matchesNodeDeep(root, template);
|
||||
return { matches, received: renderAriaTree(root, { noText: true }) };
|
||||
function matchesTextNode(text: string, template: AriaTemplateTextNode) {
|
||||
return matchesText(text, template.text);
|
||||
}
|
||||
|
||||
function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean {
|
||||
if (typeof node === 'string' && (typeof template === 'string' || template instanceof RegExp))
|
||||
return matchesText(node, template);
|
||||
function matchesName(text: string, template: AriaTemplateRoleNode) {
|
||||
return matchesText(text, template.name);
|
||||
}
|
||||
|
||||
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)
|
||||
return false;
|
||||
if (template.checked !== undefined && template.checked !== node.checked)
|
||||
|
|
@ -206,7 +255,7 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegEx
|
|||
return false;
|
||||
if (template.selected !== undefined && template.selected !== node.selected)
|
||||
return false;
|
||||
if (!matchesText(node.name, template.name))
|
||||
if (!matchesName(node.name, template))
|
||||
return false;
|
||||
if (!containsList(node.children || [], template.children || [], depth))
|
||||
return false;
|
||||
|
|
@ -215,7 +264,7 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegEx
|
|||
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)
|
||||
return false;
|
||||
const cc = children.slice();
|
||||
|
|
@ -252,54 +301,123 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean {
|
|||
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 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 (!options?.noText)
|
||||
lines.push(indent + '- text: ' + quoteYamlString(ariaNode));
|
||||
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
|
||||
return;
|
||||
const text = renderString(ariaNode);
|
||||
if (text)
|
||||
lines.push(indent + '- text: ' + text);
|
||||
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')
|
||||
line += ` [checked=mixed]`;
|
||||
key += ` [checked=mixed]`;
|
||||
if (ariaNode.checked === true)
|
||||
line += ` [checked]`;
|
||||
key += ` [checked]`;
|
||||
if (ariaNode.disabled)
|
||||
line += ` [disabled]`;
|
||||
key += ` [disabled]`;
|
||||
if (ariaNode.expanded)
|
||||
line += ` [expanded]`;
|
||||
key += ` [expanded]`;
|
||||
if (ariaNode.level)
|
||||
line += ` [level=${ariaNode.level}]`;
|
||||
key += ` [level=${ariaNode.level}]`;
|
||||
if (ariaNode.pressed === 'mixed')
|
||||
line += ` [pressed=mixed]`;
|
||||
key += ` [pressed=mixed]`;
|
||||
if (ariaNode.pressed === true)
|
||||
line += ` [pressed]`;
|
||||
key += ` [pressed]`;
|
||||
if (ariaNode.selected === true)
|
||||
line += ` [selected]`;
|
||||
key += ` [selected]`;
|
||||
|
||||
lines.push(line + (ariaNode.children.length ? ':' : ''));
|
||||
for (const child of ariaNode.children || [])
|
||||
visit(child, indent + ' ');
|
||||
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
|
||||
if (!ariaNode.children.length) {
|
||||
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') {
|
||||
// Render fragment.
|
||||
for (const child of ariaNode.children || [])
|
||||
visit(child, '');
|
||||
visit(child, ariaNode, '');
|
||||
} else {
|
||||
visit(ariaNode, '');
|
||||
visit(ariaNode, null, '');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function quoteYamlString(str: string) {
|
||||
return `"${str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')}"`;
|
||||
function convertToBestGuessRegex(text: string): string {
|
||||
const dynamicContent = [
|
||||
// 2mb
|
||||
{ regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: '[\\d,.]+[bkmBKM]+' },
|
||||
// 2ms, 20s
|
||||
{ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,10 +212,10 @@ export class InjectedScript {
|
|||
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)
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -715,7 +715,7 @@ class TextAssertionTool implements RecorderTool {
|
|||
name: 'assertSnapshot',
|
||||
selector: this._hoverHighlight.selector,
|
||||
signals: [],
|
||||
snapshot: this._recorder.injectedScript.ariaSnapshot(target),
|
||||
snapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'regex' }),
|
||||
};
|
||||
} else {
|
||||
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
|
||||
|
|
|
|||
107
packages/playwright-core/src/server/injected/yaml.ts
Normal file
107
packages/playwright-core/src/server/injected/yaml.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ import * as accessibility from './accessibility';
|
|||
import { FileChooser } from './fileChooser';
|
||||
import type { Progress } 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 { debugLogger } from '../utils/debugLogger';
|
||||
import type { ImageComparatorOptions } from '../utils/comparators';
|
||||
|
|
@ -674,11 +674,12 @@ export class Page extends SdkObject {
|
|||
throw e;
|
||||
let errorMessage = e.message;
|
||||
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 {
|
||||
log: e.message ? [...metadata.log, e.message] : metadata.log,
|
||||
log: compressCallLog(e.message ? [...metadata.log, e.message] : metadata.log),
|
||||
...intermediateResult,
|
||||
errorMessage,
|
||||
timedOut: (e instanceof TimeoutError),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import type { Frame } from './frames';
|
|||
const recorderSymbol = Symbol('recorderSymbol');
|
||||
|
||||
export class Recorder implements InstrumentationListener, IRecorder {
|
||||
readonly handleSIGINT: boolean | undefined;
|
||||
private _context: BrowserContext;
|
||||
private _mode: Mode;
|
||||
private _highlightedSelector = '';
|
||||
|
|
@ -77,6 +78,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
|||
|
||||
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) {
|
||||
this._mode = params.mode || 'none';
|
||||
this.handleSIGINT = params.handleSIGINT;
|
||||
this._contextRecorder = new ContextRecorder(codegenMode, context, params, {});
|
||||
this._context = context;
|
||||
this._omitCallTracking = !!params.omitCallTracking;
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
|
|||
noDefaultViewport: true,
|
||||
headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed),
|
||||
useWebSocket: isUnderTest(),
|
||||
handleSIGINT: false,
|
||||
handleSIGINT: recorder.handleSIGINT,
|
||||
executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined,
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type { EventEmitter } from 'events';
|
|||
export interface IRecorder {
|
||||
setMode(mode: Mode): void;
|
||||
mode(): Mode;
|
||||
readonly handleSIGINT: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface IRecorderApp extends EventEmitter {
|
||||
|
|
|
|||
|
|
@ -125,14 +125,7 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[
|
|||
for (const reporter of options.reporter || [])
|
||||
params.append('reporter', reporter);
|
||||
|
||||
let baseUrl = '';
|
||||
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()}`;
|
||||
|
||||
const urlPath = `./trace/${options.webApp || 'index.html'}?${params.toString()}`;
|
||||
server.routePath('/', (_, response) => {
|
||||
response.statusCode = 302;
|
||||
response.setHeader('Location', urlPath);
|
||||
|
|
@ -170,6 +163,7 @@ export async function openTraceViewerApp(url: string, browserName: string, optio
|
|||
...options?.persistentContextOptions,
|
||||
useWebSocket: isUnderTest(),
|
||||
headless: !!options?.headless,
|
||||
colorScheme: isUnderTest() ? 'light' : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -86,20 +86,15 @@ function calculatePlatform(): { hostPlatform: HostPlatform, isOfficiallySupporte
|
|||
return { hostPlatform: ('ubuntu22.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';
|
||||
let debianVersion = distroInfo?.version;
|
||||
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')
|
||||
if (distroInfo?.version === '11')
|
||||
return { hostPlatform: ('debian11' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform };
|
||||
if (debianVersion === '12')
|
||||
if (distroInfo?.version === '12')
|
||||
return { hostPlatform: ('debian12' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform };
|
||||
// use most recent supported release for 'debian testing' and 'unstable'.
|
||||
// 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: ('ubuntu20.04' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform: false };
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export function buildFullSelector(framePath: string[], selector: string) {
|
|||
return [...framePath, selector].join(' >> internal:control=enter-frame >> ');
|
||||
}
|
||||
|
||||
const kDefaultTimeout = 5_000;
|
||||
|
||||
export function traceParamsForAction(actionInContext: recorderActions.ActionInContext): { method: string, params: any } {
|
||||
const { action } = actionInContext;
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
|
|||
selector: action.selector,
|
||||
expression: 'to.be.checked',
|
||||
isNot: !action.checked,
|
||||
timeout: kDefaultTimeout,
|
||||
};
|
||||
return { method: 'expect', params };
|
||||
}
|
||||
|
|
@ -110,6 +113,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
|
|||
expression: 'to.have.text',
|
||||
expectedText: [],
|
||||
isNot: false,
|
||||
timeout: kDefaultTimeout,
|
||||
};
|
||||
return { method: 'expect', params };
|
||||
}
|
||||
|
|
@ -119,6 +123,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
|
|||
expression: 'to.have.value',
|
||||
expectedValue: undefined,
|
||||
isNot: false,
|
||||
timeout: kDefaultTimeout,
|
||||
};
|
||||
return { method: 'expect', params };
|
||||
}
|
||||
|
|
@ -127,6 +132,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
|
|||
selector,
|
||||
expression: 'to.be.visible',
|
||||
isNot: false,
|
||||
timeout: kDefaultTimeout,
|
||||
};
|
||||
return { method: 'expect', params };
|
||||
}
|
||||
|
|
@ -136,6 +142,7 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
|
|||
expression: 'to.match.snapshot',
|
||||
expectedText: [],
|
||||
isNot: false,
|
||||
timeout: kDefaultTimeout,
|
||||
};
|
||||
return { method: 'expect', params };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ export function escapeWithQuotes(text: string, char: string = '\'') {
|
|||
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 {
|
||||
return typeof obj === 'string' || obj instanceof String;
|
||||
}
|
||||
|
|
@ -140,3 +147,32 @@ export function escapeHTMLAttribute(s: string): string {
|
|||
export function escapeHTML(s: string): string {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,13 @@ export function splitErrorMessage(message: string): { name: string, message: str
|
|||
export function formatCallLog(log: string[] | undefined): string {
|
||||
if (!log || !log.some(l => !!l))
|
||||
return '';
|
||||
return `
|
||||
Call log:
|
||||
${colors.dim(log.join('\n'))}
|
||||
`;
|
||||
}
|
||||
|
||||
export function compressCallLog(log: string[]): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const block of findRepeatedSubsequences(log)) {
|
||||
|
|
@ -148,10 +154,7 @@ export function formatCallLog(log: string[] | undefined): string {
|
|||
lines.push(whitespacePrefix + '- ' + line.trim());
|
||||
}
|
||||
}
|
||||
return `
|
||||
Call log:
|
||||
${colors.dim(lines.join('\n'))}
|
||||
`;
|
||||
return lines;
|
||||
}
|
||||
|
||||
export type ExpectZone = {
|
||||
|
|
|
|||
|
|
@ -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 = new StackUtils({ internals: StackUtils.nodeInternals() });
|
||||
const nodeInternals = StackUtils.nodeInternals();
|
||||
const nodeMajorVersion = +process.versions.node.split('.')[0];
|
||||
|
||||
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);
|
||||
if (!frame)
|
||||
return null;
|
||||
|
|
|
|||
5
packages/playwright-core/types/types.d.ts
vendored
5
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -19819,10 +19819,7 @@ export interface FrameLocator {
|
|||
* An example to trigger select-all with the keyboard
|
||||
*
|
||||
* ```js
|
||||
* // on Windows and Linux
|
||||
* await page.keyboard.press('Control+A');
|
||||
* // on macOS
|
||||
* await page.keyboard.press('Meta+A');
|
||||
* await page.keyboard.press('ControlOrMeta+A');
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
> **BEWARE** This package is EXPERIMENTAL and does not respect semver.
|
||||
|
||||
Read more at https://playwright.dev/docs/test-components
|
||||
|
|
@ -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);
|
||||
37
packages/playwright-ct-vue2/hooks.d.ts
vendored
37
packages/playwright-ct-vue2/hooks.d.ts
vendored
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
66
packages/playwright-ct-vue2/index.d.ts
vendored
66
packages/playwright-ct-vue2/index.d.ts
vendored
|
|
@ -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';
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
24
packages/playwright-ct-vue2/register.d.ts
vendored
24
packages/playwright-ct-vue2/register.d.ts
vendored
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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)
|
||||
- fill-range@7.1.1 (https://github.com/jonschlinkert/fill-range)
|
||||
- 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)
|
||||
- globals@11.12.0 (https://github.com/sindresorhus/globals)
|
||||
- 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
|
||||
|
||||
%% 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
|
||||
=========================================
|
||||
The ISC License
|
||||
|
|
@ -4399,6 +4414,6 @@ END OF yallist@3.1.1 AND INFORMATION
|
|||
|
||||
SUMMARY BEGIN HERE
|
||||
=========================================
|
||||
Total Packages: 151
|
||||
Total Packages: 152
|
||||
=========================================
|
||||
END OF SUMMARY
|
||||
|
|
@ -23,7 +23,6 @@ import * as babel from '@babel/core';
|
|||
export { codeFrameColumns } from '@babel/code-frame';
|
||||
export { declare } from '@babel/helper-plugin-utils';
|
||||
export { types } from '@babel/core';
|
||||
export { parse } from '@babel/parser';
|
||||
import traverseFunction from '@babel/traverse';
|
||||
export const traverse = traverseFunction;
|
||||
|
||||
|
|
@ -114,16 +113,25 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins
|
|||
|
||||
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)
|
||||
return {};
|
||||
|
||||
// Prevent reentry while requiring plugins lazily.
|
||||
isTransforming = true;
|
||||
try {
|
||||
const options = babelTransformOptions(isTypeScript, isModule, pluginsPrologue, pluginsEpilogue);
|
||||
const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue);
|
||||
return babel.transform(code, { filename, ...options })!;
|
||||
} finally {
|
||||
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 })!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,35 +16,14 @@
|
|||
|
||||
// @ts-check
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
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 () => {
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: [path.join(__dirname, 'src/utilsBundleImpl.ts')],
|
||||
external: ['fsevents'],
|
||||
bundle: true,
|
||||
outdir: path.join(__dirname, '../../lib'),
|
||||
plugins: [patchSource],
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'ES2019',
|
||||
|
|
|
|||
18
packages/playwright/bundles/utils/package-lock.json
generated
18
packages/playwright/bundles/utils/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"chokidar": "3.6.0",
|
||||
"enquirer": "2.3.6",
|
||||
"get-east-asian-width": "1.3.0",
|
||||
"json5": "2.2.3",
|
||||
"pirates": "4.0.4",
|
||||
"source-map-support": "0.5.21",
|
||||
|
|
@ -146,6 +147,18 @@
|
|||
"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": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
|
|
@ -376,6 +389,11 @@
|
|||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"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": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"chokidar": "3.6.0",
|
||||
"enquirer": "2.3.6",
|
||||
"get-east-asian-width": "1.3.0",
|
||||
"json5": "2.2.3",
|
||||
"pirates": "4.0.4",
|
||||
"source-map-support": "0.5.21",
|
||||
|
|
|
|||
|
|
@ -31,3 +31,6 @@ export const enquirer = enquirerLibrary;
|
|||
|
||||
import chokidarLibrary from 'chokidar';
|
||||
export const chokidar = chokidarLibrary;
|
||||
|
||||
import * as getEastAsianWidthLibrary from 'get-east-asian-width';
|
||||
export const getEastAsianWidth = getEastAsianWidthLibrary;
|
||||
|
|
|
|||
|
|
@ -378,11 +378,6 @@ export function restartWithExperimentalTsEsm(configFile: string | undefined, for
|
|||
|
||||
// Now check for the newer API presence.
|
||||
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.
|
||||
const configIsModule = !!configFile && fileIsModule(configFile);
|
||||
if (!force && !configIsModule)
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export type StepEndPayload = {
|
|||
stepId: string;
|
||||
wallTime: number; // milliseconds since unix epoch
|
||||
error?: TestInfoErrorImpl;
|
||||
suggestedRebaseline?: string;
|
||||
};
|
||||
|
||||
export type TestEntry = {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ import {
|
|||
} from '../common/expectBundle';
|
||||
import { zones } from 'playwright-core/lib/utils';
|
||||
import { TestInfoImpl } from '../worker/testInfo';
|
||||
import { ExpectError, isExpectError } from './matcherHint';
|
||||
import { ExpectError, isJestError } from './matcherHint';
|
||||
import { toMatchAriaSnapshot } from './toMatchAriaSnapshot';
|
||||
|
||||
// #region
|
||||
|
|
@ -323,8 +323,13 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
|
|||
|
||||
const step = testInfo._addStep(stepInfo);
|
||||
|
||||
const reportStepError = (jestError: Error | unknown) => {
|
||||
const error = isExpectError(jestError) ? new ExpectError(jestError, customMessage, stackFrames) : jestError;
|
||||
const reportStepError = (e: Error | unknown) => {
|
||||
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 });
|
||||
if (this._info.isSoft)
|
||||
testInfo._failWithError(error);
|
||||
|
|
|
|||
|
|
@ -33,16 +33,13 @@ export function matcherHint(state: ExpectMatcherState, locator: Locator | undefi
|
|||
|
||||
export type MatcherResult<E, A> = {
|
||||
name: string;
|
||||
expected: E;
|
||||
expected?: E;
|
||||
message: () => string;
|
||||
pass: boolean;
|
||||
actual?: A;
|
||||
log?: string[];
|
||||
timeout?: number;
|
||||
locator?: string;
|
||||
printedReceived?: string;
|
||||
printedExpected?: string;
|
||||
printedDiff?: string;
|
||||
suggestedRebaseline?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
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 { expectTypes, callLogText } from '../util';
|
||||
import { toBeTruthy } from './toBeTruthy';
|
||||
|
|
@ -28,7 +28,7 @@ import type { ExpectMatcherState } from '../../types/test';
|
|||
import { takeFirst } from '../common/config';
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,5 @@ export async function toBeTruthy(
|
|||
expected,
|
||||
log,
|
||||
timeout: timedOut ? timeout : undefined,
|
||||
...(printedReceived ? { printedReceived } : {}),
|
||||
...(printedExpected ? { printedExpected } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,8 +83,5 @@ export async function toEqual<T>(
|
|||
pass,
|
||||
log,
|
||||
timeout: timedOut ? timeout : undefined,
|
||||
...(printedReceived ? { printedReceived } : {}),
|
||||
...(printedExpected ? { printedExpected } : {}),
|
||||
...(printedDiff ? { printedDiff } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import { colors } from 'playwright-core/lib/utilsBundle';
|
|||
import { EXPECTED_COLOR } from '../common/expectBundle';
|
||||
import { callLogText } from '../util';
|
||||
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(
|
||||
this: ExpectMatcherState,
|
||||
|
|
@ -31,6 +34,15 @@ export async function toMatchAriaSnapshot(
|
|||
): Promise<MatcherResult<string | RegExp, string>> {
|
||||
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 = {
|
||||
isNot: this.isNot,
|
||||
promise: this.promise,
|
||||
|
|
@ -44,27 +56,57 @@ export async function toMatchAriaSnapshot(
|
|||
].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;
|
||||
expected = unshift(expected);
|
||||
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 notFound = received === kNoElementsFoundError;
|
||||
const escapedExpected = unshift(escapePrivateUsePoints(expected));
|
||||
const escapedReceived = unshift(escapePrivateUsePoints(received));
|
||||
const notFound = typedReceived === kNoElementsFoundError;
|
||||
if (notFound) {
|
||||
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 = () => {
|
||||
if (pass) {
|
||||
if (notFound)
|
||||
return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
|
||||
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 {
|
||||
const labelExpected = `Expected`;
|
||||
if (notFound)
|
||||
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 {
|
||||
name: matcherName,
|
||||
expected,
|
||||
|
|
@ -77,7 +119,7 @@ export async function toMatchAriaSnapshot(
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -93,3 +135,7 @@ function unshift(snapshot: string): string {
|
|||
}
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type { Locator, Page } from 'playwright-core';
|
|||
import type { ExpectScreenshotOptions, Page as PageEx } from 'playwright-core/lib/client/page';
|
||||
import { currentTestInfo } from '../common/globals';
|
||||
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 {
|
||||
addSuffixToFilePath,
|
||||
trimLongString, callLogText,
|
||||
|
|
@ -31,7 +31,7 @@ import path from 'path';
|
|||
import { mime } from 'playwright-core/lib/utilsBundle';
|
||||
import type { TestInfoImpl } from '../worker/testInfo';
|
||||
import type { ExpectMatcherState } from '../../types/test';
|
||||
import type { MatcherResult } from './matcherHint';
|
||||
import { matcherHint, type MatcherResult } from './matcherHint';
|
||||
import type { FullProjectInternal } from '../common/config';
|
||||
|
||||
type NameOrSegments = string | string[];
|
||||
|
|
@ -194,10 +194,6 @@ class SnapshotHelper {
|
|||
pass,
|
||||
message: () => message,
|
||||
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;
|
||||
}
|
||||
|
|
@ -250,16 +246,10 @@ class SnapshotHelper {
|
|||
expected: Buffer | string | undefined,
|
||||
previous: Buffer | string | undefined,
|
||||
diff: Buffer | string | undefined,
|
||||
diffError: string | undefined,
|
||||
log: string[] | undefined,
|
||||
title = `${this.kind} comparison failed:`): ImageMatcherResult {
|
||||
const output = [
|
||||
colors.red(title),
|
||||
'',
|
||||
];
|
||||
if (diffError)
|
||||
output.push(indent(diffError, ' '));
|
||||
|
||||
header: string,
|
||||
diffError: string,
|
||||
log: string[] | undefined): ImageMatcherResult {
|
||||
const output = [`${header}${indent(diffError, ' ')}`];
|
||||
if (expected !== undefined) {
|
||||
// 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.
|
||||
|
|
@ -338,7 +328,9 @@ export function toMatchSnapshot(
|
|||
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(
|
||||
|
|
@ -374,6 +366,7 @@ export async function toHaveScreenshot(
|
|||
throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have '.png' extension`);
|
||||
expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot');
|
||||
const style = await loadScreenshotStyles(helper.options.stylePath);
|
||||
const timeout = helper.options.timeout ?? this.timeout;
|
||||
const expectScreenshotOptions: ExpectScreenshotOptions = {
|
||||
locator,
|
||||
animations: helper.options.animations ?? 'disabled',
|
||||
|
|
@ -386,7 +379,7 @@ export async function toHaveScreenshot(
|
|||
scale: helper.options.scale ?? 'css',
|
||||
style,
|
||||
isNot: !!this.isNot,
|
||||
timeout: helper.options.timeout ?? this.timeout,
|
||||
timeout,
|
||||
comparator: helper.options.comparator,
|
||||
maxDiffPixels: helper.options.maxDiffPixels,
|
||||
maxDiffPixelRatio: helper.options.maxDiffPixelRatio,
|
||||
|
|
@ -410,13 +403,16 @@ export async function toHaveScreenshot(
|
|||
if (helper.updateSnapshots === 'none' && !hasSnapshot)
|
||||
return helper.createMatcherResult(`A snapshot doesn't exist at ${helper.expectedPath}.`, false);
|
||||
|
||||
const receiver = locator ? 'locator' : 'page';
|
||||
if (!hasSnapshot) {
|
||||
// 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.
|
||||
// This can be due to e.g. spinning animation, so we want to show it as a diff.
|
||||
if (errorMessage)
|
||||
return helper.handleDifferent(actual, undefined, previous, diff, undefined, log, errorMessage);
|
||||
if (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.
|
||||
return helper.handleMissing(actual!);
|
||||
|
|
@ -427,7 +423,7 @@ export async function toHaveScreenshot(
|
|||
// - regular matcher (i.e. not a `.not`)
|
||||
// - perhaps an 'all' flag to update non-matching screenshots
|
||||
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)
|
||||
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.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) {
|
||||
|
|
|
|||
|
|
@ -118,10 +118,5 @@ export async function toMatchText(
|
|||
actual: received,
|
||||
log,
|
||||
timeout: timedOut ? timeout : undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
locator: receiver.toString(),
|
||||
...(printedReceived ? { printedReceived } : {}),
|
||||
...(printedExpected ? { printedExpected } : {}),
|
||||
...(printedDiff ? { printedDiff } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { colors as realColors, ms as milliseconds, parseStackTraceLine } from 'p
|
|||
import path from 'path';
|
||||
import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter';
|
||||
import { getPackageManagerExecCommand } from 'playwright-core/lib/utils';
|
||||
import { getEastAsianWidth } from '../utilsBundle';
|
||||
import type { ReporterV2 } from './reporterV2';
|
||||
import { resolveReporterOutputPath } from '../util';
|
||||
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
|
||||
|
|
@ -26,13 +27,6 @@ export const kOutputSymbol = Symbol('output');
|
|||
type ErrorDetails = {
|
||||
message: string;
|
||||
location?: Location;
|
||||
timeout?: number;
|
||||
matcherName?: string;
|
||||
locator?: string;
|
||||
expected?: string;
|
||||
received?: string;
|
||||
log?: string[];
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
type TestSummary = {
|
||||
|
|
@ -362,13 +356,6 @@ export function formatResultFailure(test: TestCase, result: TestResult, initialI
|
|||
errorDetails.push({
|
||||
message: indent(formattedError.message, initialIndent),
|
||||
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;
|
||||
|
|
@ -448,15 +435,16 @@ export function formatError(error: TestError, highlightCode: boolean): ErrorDeta
|
|||
tokens.push(snippet);
|
||||
}
|
||||
|
||||
if (parsedStack && parsedStack.stackLines.length) {
|
||||
tokens.push('');
|
||||
if (parsedStack && parsedStack.stackLines.length)
|
||||
tokens.push(colors.dim(parsedStack.stackLines.join('\n')));
|
||||
}
|
||||
|
||||
let location = error.location;
|
||||
if (parsedStack && !location)
|
||||
location = parsedStack.location;
|
||||
|
||||
if (error.cause)
|
||||
tokens.push(colors.dim('[cause]: ') + formatError(error.cause, highlightCode).message);
|
||||
|
||||
return {
|
||||
location,
|
||||
message: tokens.join('\n'),
|
||||
|
|
@ -503,11 +491,35 @@ export function stripAnsiEscapes(str: string): string {
|
|||
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.
|
||||
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;
|
||||
width -= prefixLength;
|
||||
if (line.length <= width)
|
||||
if (stringWidth(line) <= width)
|
||||
return line;
|
||||
|
||||
// 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.
|
||||
taken.push(parts[i]);
|
||||
} else {
|
||||
let part = parts[i].substring(parts[i].length - width);
|
||||
if (part.length < parts[i].length && part.length > 0) {
|
||||
let part = suffixOfWidth(parts[i], width);
|
||||
const wasTruncated = part.length < parts[i].length;
|
||||
if (wasTruncated && parts[i].length > 0) {
|
||||
// Add ellipsis if we are truncating.
|
||||
part = '\u2026' + part.substring(1);
|
||||
part = '\u2026' + suffixOfWidth(parts[i], width - 1);
|
||||
}
|
||||
taken.push(part);
|
||||
width -= part.length;
|
||||
width -= stringWidth(part);
|
||||
}
|
||||
}
|
||||
return taken.reverse().join('');
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type { FullConfigInternal } from '../common/config';
|
|||
import type { ReporterV2 } from '../reporters/reporterV2';
|
||||
import type { FailureTracker } from './failureTracker';
|
||||
import { colors } from 'playwright-core/lib/utilsBundle';
|
||||
import { addSuggestedRebaseline } from './rebase';
|
||||
|
||||
export type EnvByProjectId = Map<string, Record<string, string | undefined>>;
|
||||
|
||||
|
|
@ -341,6 +342,8 @@ class JobDispatcher {
|
|||
step.duration = params.wallTime - step.startTime.getTime();
|
||||
if (params.error)
|
||||
step.error = params.error;
|
||||
if (params.suggestedRebaseline)
|
||||
addSuggestedRebaseline(step.location!, params.suggestedRebaseline);
|
||||
steps.delete(params.stepId);
|
||||
this._reporter.onStepEnd?.(test, result, step);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { applyRepeatEachIndex, bindFileSuiteToProject, filterByFocusedLine, filt
|
|||
import { createTestGroups, filterForShard, type TestGroup } from './testGroups';
|
||||
import { dependenciesForTestFile } from '../transform/compilationCache';
|
||||
import { sourceMapSupport } from '../utilsBundle';
|
||||
import type { RawSourceMap } from 'source-map';
|
||||
import type { RawSourceMap } from '../utilsBundle';
|
||||
|
||||
|
||||
export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean) {
|
||||
|
|
|
|||
107
packages/playwright/src/runner/rebase.ts
Normal file
107
packages/playwright/src/runner/rebase.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import type { FullConfigInternal } from '../common/config';
|
|||
import { affectedTestFiles } from '../transform/compilationCache';
|
||||
import { InternalReporter } from '../reporters/internalReporter';
|
||||
import { LastRunReporter } from './lastRun';
|
||||
import { applySuggestedRebaselines } from './rebase';
|
||||
|
||||
type ProjectConfigWithFiles = {
|
||||
name: string;
|
||||
|
|
@ -88,6 +89,8 @@ export class Runner {
|
|||
];
|
||||
const status = await runTasks(new TestRun(config, reporter), tasks, config.config.globalTimeout);
|
||||
|
||||
await applySuggestedRebaselines(config);
|
||||
|
||||
// Calling process.exit() might truncate large stdout/stderr output.
|
||||
// See https://github.com/nodejs/node/issues/6456.
|
||||
// See https://github.com/nodejs/node/issues/12921
|
||||
|
|
|
|||
|
|
@ -14,14 +14,15 @@
|
|||
* 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 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 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 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 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 { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils';
|
||||
|
|
|
|||
|
|
@ -215,7 +215,6 @@ export function setTransformData(pluginName: string, value: 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 =
|
||||
process.env.PW_TEST_SOURCE_TRANSFORM &&
|
||||
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');
|
||||
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)
|
||||
return { code: '', serializedCache };
|
||||
const added = addToCache!(code, map, transformData);
|
||||
|
|
|
|||
|
|
@ -29,15 +29,17 @@ import type { TestInfoErrorImpl } from './common/ipc';
|
|||
const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..');
|
||||
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 cause = e.cause instanceof Error ? filterStackTrace(e.cause) : undefined;
|
||||
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') || []));
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 getEastAsianWidth: typeof import('../bundles/utils/node_modules/get-east-asian-width') = require('./utilsBundleImpl').getEastAsianWidth;
|
||||
export type { RawSourceMap } from '../bundles/utils/node_modules/source-map';
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import type { StackFrame } from '@protocol/channels';
|
|||
import { testInfoError } from './util';
|
||||
|
||||
export interface TestStepInternal {
|
||||
complete(result: { error?: Error | unknown, attachments?: Attachment[] }): void;
|
||||
complete(result: { error?: Error | unknown, attachments?: Attachment[], suggestedRebaseline?: string }): void;
|
||||
stepId: string;
|
||||
title: string;
|
||||
category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string;
|
||||
|
|
@ -297,6 +297,7 @@ export class TestInfoImpl implements TestInfo {
|
|||
stepId,
|
||||
wallTime: step.endWallTime,
|
||||
error: step.error,
|
||||
suggestedRebaseline: result.suggestedRebaseline,
|
||||
};
|
||||
this._onStepEnd(payload);
|
||||
const errorForTrace = step.error ? { name: '', message: step.error.message || '', stack: step.error.stack } : undefined;
|
||||
|
|
|
|||
|
|
@ -224,11 +224,18 @@ export class TestTracing {
|
|||
const stack = rawStack ? filteredStackTrace(rawStack) : [];
|
||||
this._appendTraceEvent({
|
||||
type: 'error',
|
||||
message: error.message || String(error.value),
|
||||
message: this._formatError(error),
|
||||
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) {
|
||||
this._appendTraceEvent({
|
||||
type,
|
||||
|
|
|
|||
7
packages/playwright/types/test.d.ts
vendored
7
packages/playwright/types/test.d.ts
vendored
|
|
@ -9152,6 +9152,13 @@ export interface TestInfo {
|
|||
* Information about an error thrown during test execution.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
31
packages/playwright/types/testReporter.d.ts
vendored
31
packages/playwright/types/testReporter.d.ts
vendored
|
|
@ -555,40 +555,22 @@ export interface TestCase {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
message?: string;
|
||||
|
||||
/**
|
||||
* Received value formatted as a human-readable string.
|
||||
*/
|
||||
received?: string;
|
||||
|
||||
/**
|
||||
* Source code snippet with highlighted error.
|
||||
*/
|
||||
|
|
@ -599,11 +581,6 @@ export interface TestError {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1777,6 +1777,7 @@ export type BrowserContextEnableRecorderParams = {
|
|||
device?: string,
|
||||
saveStorage?: string,
|
||||
outputFile?: string,
|
||||
handleSIGINT?: boolean,
|
||||
omitCallTracking?: boolean,
|
||||
};
|
||||
export type BrowserContextEnableRecorderOptions = {
|
||||
|
|
@ -1790,6 +1791,7 @@ export type BrowserContextEnableRecorderOptions = {
|
|||
device?: string,
|
||||
saveStorage?: string,
|
||||
outputFile?: string,
|
||||
handleSIGINT?: boolean,
|
||||
omitCallTracking?: boolean,
|
||||
};
|
||||
export type BrowserContextEnableRecorderResult = void;
|
||||
|
|
@ -2139,7 +2141,7 @@ export type PageReloadResult = {
|
|||
};
|
||||
export type PageExpectScreenshotParams = {
|
||||
expected?: Binary,
|
||||
timeout?: number,
|
||||
timeout: number,
|
||||
isNot: boolean,
|
||||
locator?: {
|
||||
frame: FrameChannel,
|
||||
|
|
@ -2164,7 +2166,6 @@ export type PageExpectScreenshotParams = {
|
|||
};
|
||||
export type PageExpectScreenshotOptions = {
|
||||
expected?: Binary,
|
||||
timeout?: number,
|
||||
locator?: {
|
||||
frame: FrameChannel,
|
||||
selector: string,
|
||||
|
|
@ -2191,6 +2192,7 @@ export type PageExpectScreenshotResult = {
|
|||
errorMessage?: string,
|
||||
actual?: Binary,
|
||||
previous?: Binary,
|
||||
timedOut?: boolean,
|
||||
log?: string[],
|
||||
};
|
||||
export type PageScreenshotParams = {
|
||||
|
|
@ -3160,7 +3162,7 @@ export type FrameExpectParams = {
|
|||
expectedValue?: SerializedArgument,
|
||||
useInnerText?: boolean,
|
||||
isNot: boolean,
|
||||
timeout?: number,
|
||||
timeout: number,
|
||||
};
|
||||
export type FrameExpectOptions = {
|
||||
expressionArg?: any,
|
||||
|
|
@ -3168,7 +3170,6 @@ export type FrameExpectOptions = {
|
|||
expectedNumber?: number,
|
||||
expectedValue?: SerializedArgument,
|
||||
useInnerText?: boolean,
|
||||
timeout?: number,
|
||||
};
|
||||
export type FrameExpectResult = {
|
||||
matches: boolean,
|
||||
|
|
|
|||
|
|
@ -1208,6 +1208,7 @@ BrowserContext:
|
|||
device: string?
|
||||
saveStorage: string?
|
||||
outputFile: string?
|
||||
handleSIGINT: boolean?
|
||||
omitCallTracking: boolean?
|
||||
|
||||
newCDPSession:
|
||||
|
|
@ -1481,7 +1482,7 @@ Page:
|
|||
expectScreenshot:
|
||||
parameters:
|
||||
expected: binary?
|
||||
timeout: number?
|
||||
timeout: number
|
||||
isNot: boolean
|
||||
locator:
|
||||
type: object?
|
||||
|
|
@ -1500,6 +1501,7 @@ Page:
|
|||
errorMessage: string?
|
||||
actual: binary?
|
||||
previous: binary?
|
||||
timedOut: boolean?
|
||||
log:
|
||||
type: array?
|
||||
items: string
|
||||
|
|
@ -2386,7 +2388,7 @@ Frame:
|
|||
expectedValue: SerializedArgument?
|
||||
useInnerText: boolean?
|
||||
isNot: boolean
|
||||
timeout: number?
|
||||
timeout: number
|
||||
returns:
|
||||
matches: boolean
|
||||
received: SerializedValue?
|
||||
|
|
|
|||
2
packages/trace-viewer/.gitignore
vendored
2
packages/trace-viewer/.gitignore
vendored
|
|
@ -22,5 +22,3 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
public/sw.bundle.js*
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader';
|
|||
if (window.location.protocol !== 'file:') {
|
||||
if (!navigator.serviceWorker)
|
||||
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) {
|
||||
await new Promise<void>(f => {
|
||||
navigator.serviceWorker.oncontrollerchange = () => f();
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { WorkbenchLoader } from './ui/workbenchLoader';
|
|||
await new Promise(f => setTimeout(f, 1000));
|
||||
if (!navigator.serviceWorker)
|
||||
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) {
|
||||
await new Promise<void>(f => {
|
||||
navigator.serviceWorker.oncontrollerchange = () => f();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { RecorderView } from './ui/recorder/recorderView';
|
|||
if (window.location.protocol !== 'file:') {
|
||||
if (!navigator.serviceWorker)
|
||||
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) {
|
||||
await new Promise<void>(f => {
|
||||
navigator.serviceWorker.oncontrollerchange = () => f();
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ export class ZipTraceModelBackend implements TraceModelBackend {
|
|||
|
||||
constructor(traceURL: string, progress: Progress) {
|
||||
this._traceURL = traceURL;
|
||||
zipjs.configure({ baseURL: self.location.href } as any);
|
||||
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 });
|
||||
this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => {
|
||||
const map = new Map<string, zip.Entry>();
|
||||
|
|
@ -86,7 +87,7 @@ export class FetchTraceModelBackend implements TraceModelBackend {
|
|||
|
||||
constructor(traceURL: string) {
|
||||
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 entries = new Map<string, string>();
|
||||
for (const entry of json.entries)
|
||||
|
|
@ -128,22 +129,14 @@ export class FetchTraceModelBackend implements TraceModelBackend {
|
|||
const fileName = entries.get(entryName);
|
||||
if (!fileName)
|
||||
return;
|
||||
|
||||
return fetch(formatTraceFileUrl(fileName));
|
||||
return fetch('/trace/file?path=' + encodeURIComponent(fileName));
|
||||
}
|
||||
}
|
||||
|
||||
const baseURL = new URL(self.location.href);
|
||||
baseURL.port = baseURL.searchParams.get('testServerPort') ?? baseURL.port;
|
||||
|
||||
function formatTraceFileUrl(trace: string) {
|
||||
if (trace.startsWith('https://www.dropbox.com/'))
|
||||
return 'https://dl.dropboxusercontent.com/' + trace.substring('https://www.dropbox.com/'.length);
|
||||
|
||||
if (trace.startsWith('http') || trace.startsWith('blob'))
|
||||
return trace;
|
||||
|
||||
const url = new URL('/trace/file', baseURL);
|
||||
url.searchParams.set('path', trace);
|
||||
return url.toString();
|
||||
function formatUrl(trace: string) {
|
||||
let url = trace.startsWith('http') || trace.startsWith('blob') ? trace : `file?path=${encodeURIComponent(trace)}`;
|
||||
// Dropbox does not support cors.
|
||||
if (url.startsWith('https://www.dropbox.com/'))
|
||||
url = 'https://dl.dropboxusercontent.com/' + url.substring('https://www.dropbox.com/'.length);
|
||||
return url;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue