Merge branch 'main' into hmr-take-2

This commit is contained in:
Simon Knott 2024-11-04 16:27:25 +01:00
commit dd9184701e
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
87 changed files with 1636 additions and 543 deletions

View file

@ -35,7 +35,7 @@ jobs:
run: |
npx playwright merge-reports --config .github/workflows/merge.config.ts ./all-blob-reports
env:
NODE_OPTIONS: --max-old-space-size=4096
NODE_OPTIONS: --max-old-space-size=8192
- name: Azure Login
uses: azure/login@v2

View file

@ -268,23 +268,6 @@ jobs:
- run: npx playwright install-deps
- run: utils/build/build-playwright-driver.sh
test_linux_chromium_headless_new:
name: Linux Chromium Headless New
environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/run-test
with:
browsers-to-install: chromium
command: npm run ctest
bot-name: "headless-new"
flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }}
flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }}
flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }}
env:
PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW: 1
test_linux_chromium_headless_shell:
name: Chromium Headless Shell
environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }}

View file

@ -1,6 +1,6 @@
# 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-131.0.6778.13-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-131.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord)
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-131.0.6778.24-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-131.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord)
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
@ -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.13<!-- 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: |

View file

@ -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

View file

@ -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

View file

@ -111,16 +111,21 @@ def test_visit_admin_dashboard(page: Page):
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.
### Configure slow mo
### Using multiple contexts
Run tests with slow mo with the `--slowmo` argument.
In order to simulate multiple users, you can create multiple [`BrowserContext`](./browser-contexts) instances.
```bash
pytest --slowmo 100
```py title="test_my_application.py"
from playwright.sync_api import Page, BrowserContext
from pytest_playwright.pytest_playwright import CreateContextCallback
def test_foo(page: Page, new_context: CreateContextCallback) -> None:
page.goto("https://example.com")
context = new_context()
page2 = context.new_page()
# page and page2 are in different contexts
```
Slows down Playwright operations by 100 milliseconds.
### Skip test by browser
```py title="test_my_application.py"
@ -198,7 +203,7 @@ def browser_context_args(browser_context_args):
}
```
### Device emulation
### Device emulation / BrowserContext option overrides
```py title="conftest.py"
import pytest

27
package-lock.json generated
View file

@ -2412,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",
@ -7895,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",

View file

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

View file

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

View file

@ -30,8 +30,12 @@ export const Chip: React.FC<{
dataTestId?: string,
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>;
};

View file

@ -33,6 +33,11 @@ test('should render counters', async ({ mount }) => {
await expect(component.locator('a', { hasText: 'Failed' }).locator('.counter')).toHaveText('31');
await expect(component.locator('a', { hasText: '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 }) => {

View file

@ -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>

View file

@ -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 = () => {

View file

@ -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;

View file

@ -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;
}
}

View file

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

View file

@ -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"
`);
});

View file

@ -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'>

View file

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

View file

@ -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 => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
return ansi2html(text || '', defaultColors);
}

View file

@ -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));

View file

@ -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>}
</>;
};

View file

@ -3,15 +3,15 @@
"browsers": [
{
"name": "chromium",
"revision": "1145",
"revision": "1146",
"installByDefault": true,
"browserVersion": "131.0.6778.13"
"browserVersion": "131.0.6778.24"
},
{
"name": "chromium-tip-of-tree",
"revision": "1271",
"revision": "1274",
"installByDefault": false,
"browserVersion": "132.0.6791.0"
"browserVersion": "132.0.6809.0"
},
{
"name": "firefox",
@ -27,7 +27,7 @@
},
{
"name": "webkit",
"revision": "2097",
"revision": "2102",
"installByDefault": true,
"revisionOverrides": {
"mac10.14": "1446",

View file

@ -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;
}
}

View file

@ -112,10 +112,7 @@ export class BidiChromium extends BrowserType {
if (options.devtools)
chromeArguments.push('--auto-open-devtools-for-tabs');
if (options.headless) {
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
chromeArguments.push('--headless=new');
else
chromeArguments.push('--headless=old');
chromeArguments.push('--headless');
chromeArguments.push(
'--hide-scrollbars',

View file

@ -309,10 +309,7 @@ export class Chromium extends BrowserType {
if (options.devtools)
chromeArguments.push('--auto-open-devtools-for-tabs');
if (options.headless) {
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
chromeArguments.push('--headless=new');
else
chromeArguments.push('--headless=old');
chromeArguments.push('--headless');
chromeArguments.push(
'--hide-scrollbars',

View file

@ -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) {

View file

@ -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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 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.13 Safari/537.36 Edg/131.0.6778.13",
"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.13 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.13 Safari/537.36 Edg/131.0.6778.13",
"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

View file

@ -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);
}
}

View file

@ -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`);

View file

@ -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';
@ -1452,7 +1452,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 => {
@ -1473,7 +1473,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)

View file

@ -17,6 +17,8 @@
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';
@ -33,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)
@ -55,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') ? ' ' : '';
@ -84,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 = [];
}
@ -131,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) {
@ -170,7 +201,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
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)
@ -180,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) };
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)
@ -205,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;
@ -214,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();
@ -251,62 +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 += ` ${quoteYamlString(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]`;
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
if (!ariaNode.children.length) {
lines.push(line);
lines.push(escapedKey);
} else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string') {
if (!options?.noText)
line += ': ' + quoteYamlString(ariaNode.children[0]);
lines.push(line);
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(line + ':');
lines.push(escapedKey + ':');
for (const child of ariaNode.children || [])
visit(child, indent + ' ');
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;
}

View file

@ -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[] {

View file

@ -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 });

View file

@ -0,0 +1,107 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function yamlEscapeKeyIfNeeded(str: string): string {
if (!yamlStringNeedsQuotes(str))
return str;
return `'` + str.replace(/'/g, `''`) + `'`;
}
export function yamlEscapeValueIfNeeded(str: string): string {
if (!yamlStringNeedsQuotes(str))
return str;
return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => {
switch (c) {
case '\\':
return '\\\\';
case '"':
return '\\"';
case '\b':
return '\\b';
case '\f':
return '\\f';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
default:
const code = c.charCodeAt(0);
return '\\x' + code.toString(16).padStart(2, '0');
}
}) + '"';
}
export function yamlQuoteFragment(str: string, quote = '"'): string {
return quote + str.replace(/['"]/g, c => {
switch (c) {
case '"':
return quote === '"' ? '\\"' : '"';
case '\'':
return quote === '\'' ? '\\\'' : '\'';
default:
return c;
}
}) + quote;
}
function yamlStringNeedsQuotes(str: string): boolean {
if (str.length === 0)
return true;
// Strings with leading or trailing whitespace need quotes
if (/^\s|\s$/.test(str))
return true;
// Strings containing control characters need quotes
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
return true;
// Strings starting with '-' followed by a space need quotes
if (/^-\s/.test(str))
return true;
// Strings that start with a special indicator character need quotes
if (/^[&*].*/.test(str))
return true;
// Strings containing ':' followed by a space or at the end need quotes
if (/:(\s|$)/.test(str))
return true;
// Strings containing '#' preceded by a space need quotes (comment indicator)
if (/\s#/.test(str))
return true;
// Strings that contain line breaks need quotes
if (/[\n\r]/.test(str))
return true;
// Strings starting with '?' or '!' (directives) need quotes
if (/^[?!]/.test(str))
return true;
// Strings starting with '>' or '|' (block scalar indicators) need quotes
if (/^[>|]/.test(str))
return true;
// Strings containing special characters that could cause ambiguity
if (/[{}`]/.test(str))
return true;
return false;
}

View file

@ -31,7 +31,7 @@ import * as accessibility from './accessibility';
import { FileChooser } from './fileChooser';
import 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';
@ -676,7 +676,7 @@ export class Page extends SdkObject {
if (e instanceof TimeoutError && intermediateResult?.previous)
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),

View file

@ -30,6 +30,8 @@ import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/re
import { metadataToCallLog } from './recorder/recorderUtils';
import type * as actions from '@recorder/actions';
import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
import { stringifySelector } from '../utils/isomorphic/selectorParser';
import type { Frame } from './frames';
const recorderSymbol = Symbol('recorderSymbol');
@ -146,12 +148,12 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._pushAllSources();
});
await this._context.exposeBinding('__pw_recorderState', false, source => {
await this._context.exposeBinding('__pw_recorderState', false, async source => {
let actionSelector = '';
let actionPoint: Point | undefined;
const hasActiveScreenshotCommand = [...this._currentCallsMetadata.keys()].some(isScreenshotCommand);
if (!hasActiveScreenshotCommand) {
actionSelector = this._highlightedSelector;
actionSelector = await this._scopeHighlightedSelectorToFrame(source.frame);
for (const [metadata, sdkObject] of this._currentCallsMetadata) {
if (source.page === sdkObject.attribution.page) {
actionPoint = metadata.point || actionPoint;
@ -243,13 +245,38 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._refreshOverlay();
}
private async _scopeHighlightedSelectorToFrame(frame: Frame): Promise<string> {
try {
const mainFrame = frame._page.mainFrame();
const resolved = await mainFrame.selectors.resolveFrameForSelector(this._highlightedSelector);
// selector couldn't be found, don't highlight anything
if (!resolved)
return '';
// selector points to no specific frame, highlight in all frames
if (resolved?.frame === mainFrame)
return stringifySelector(resolved.info.parsed);
// selector points to this frame, highlight it
if (resolved?.frame === frame)
return stringifySelector(resolved.info.parsed);
// selector points to a different frame, highlight nothing
return '';
} catch {
return '';
}
}
setOutput(codegenId: string, outputFile: string | undefined) {
this._contextRecorder.setOutput(codegenId, outputFile);
}
private _refreshOverlay() {
for (const page of this._context.pages())
page.mainFrame().evaluateExpression('window.__pw_refreshOverlay()').catch(() => {});
for (const page of this._context.pages()) {
for (const frame of page.frames())
frame.evaluateExpression('window.__pw_refreshOverlay()').catch(() => {});
}
}
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {

View file

@ -112,6 +112,7 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[
continue;
}
// <testServerOrigin>/trace/file?path=/path/to/trace.zip
const url = new URL('/trace/file', server.urlPrefix('precise'));
url.searchParams.set('path', traceUrl);
params.append('trace', url.toString());
@ -178,6 +179,7 @@ export async function openTraceViewerApp(url: string, browserName: string, optio
...options?.persistentContextOptions,
useWebSocket: isUnderTest(),
headless: !!options?.headless,
colorScheme: isUnderTest() ? 'light' : undefined,
},
});

View file

@ -86,20 +86,15 @@ function calculatePlatform(): { hostPlatform: HostPlatform, isOfficiallySupporte
return { hostPlatform: ('ubuntu22.04' + archSuffix) as HostPlatform, isOfficiallySupportedPlatform: false };
return { hostPlatform: ('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 };

View file

@ -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);
}

View file

@ -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 = {

View file

@ -112,6 +112,7 @@ This project incorporates components from the projects listed below. The origina
- escape-string-regexp@2.0.0 (https://github.com/sindresorhus/escape-string-regexp)
- 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

View file

@ -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 })!;
}

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -23,6 +23,8 @@ 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,
@ -70,28 +72,38 @@ export async function toMatchAriaSnapshot(
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 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(received);
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${indent(received, '${indent} ')}\n\${indent}\`)`;
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`;
return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline };
}
@ -107,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 {

View file

@ -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' };
@ -490,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.
@ -505,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('');

View file

@ -17,9 +17,10 @@
import path from 'path';
import fs from 'fs';
import type { T } from '../transform/babelBundle';
import { types, traverse, parse } 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;
@ -45,15 +46,20 @@ export function addSuggestedRebaseline(location: Location, 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;
for (const fileName of suggestedRebaselines.keys()) {
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 = parse(source, { sourceType: 'module' });
const fileNode = babelParse(source, fileName, true);
const ranges: { start: number, end: number, oldText: string, newText: string }[] = [];
traverse(fileNode, {
@ -75,7 +81,7 @@ export async function applySuggestedRebaselines(config: FullConfigInternal) {
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);
const newText = replacement.code.replace(/\{indent\}/g, indent);
ranges.push({ start: matcher.start!, end: node.end!, oldText: source.substring(matcher.start!, node.end!), newText });
}
}
@ -87,9 +93,15 @@ export async function applySuggestedRebaselines(config: FullConfigInternal) {
result = result.substring(0, range.start) + range.newText + result.substring(range.end);
const relativeName = path.relative(process.cwd(), fileName);
const patchFile = path.join(project.project.outputDir, 'rebaselines.patch');
await fs.promises.mkdir(path.dirname(patchFile), { recursive: true });
await fs.promises.writeFile(patchFile, generateUnifiedDiff(source, result, relativeName));
files.push(relativeName);
patches.push(generateUnifiedDiff(source, result, relativeName.replace(/\\/g, '/')));
}
const patchFile = path.join(project.project.outputDir, 'rebaselines.patch');
await fs.promises.mkdir(path.dirname(patchFile), { recursive: true });
await fs.promises.writeFile(patchFile, patches.join('\n'));
const fileList = files.map(file => ' ' + colors.dim(file)).join('\n');
// eslint-disable-next-line no-console
console.log(`New baselines created for:\n\n${fileList}\n\n ` + colors.cyan('git apply ' + path.relative(process.cwd(), patchFile)) + '\n');
}

View file

@ -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';

View file

@ -215,7 +215,6 @@ export function setTransformData(pluginName: string, value: any) {
}
export function transformHook(originalCode: string, filename: string, moduleUrl?: string): { code: string, serializedCache?: any } {
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);

View file

@ -20,4 +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';

View file

@ -30,7 +30,6 @@ 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(traceURL, { mode: 'cors', preventHeadRequest: true } as any),
{ useWebWorkers: false });
@ -82,19 +81,16 @@ export class ZipTraceModelBackend implements TraceModelBackend {
}
export class FetchTraceModelBackend implements TraceModelBackend {
private _entriesPromise: Promise<Map<string, URL>>;
private _entriesPromise: Promise<Map<string, string>>;
private _traceURL: string;
constructor(traceURL: string) {
this._traceURL = traceURL;
this._entriesPromise = fetch(traceURL).then(async response => {
const json = JSON.parse(await response.text());
const entries = new Map<string, URL>();
for (const entry of json.entries) {
const entryURL = new URL(traceURL);
entryURL.searchParams.set('path', entry.path);
entries.set(entry.name, entryURL);
}
const json = await response.json();
const entries = new Map<string, string>();
for (const entry of json.entries)
entries.set(entry.name, entry.path);
return entries;
});
}
@ -129,9 +125,12 @@ export class FetchTraceModelBackend implements TraceModelBackend {
private async _readEntry(entryName: string): Promise<Response | undefined> {
const entries = await this._entriesPromise;
const fileURL = entries.get(entryName);
if (!fileURL)
const filePath = entries.get(entryName);
if (!filePath)
return;
return fetch(fileURL);
const url = new URL(this.traceURL());
url.searchParams.set('path', filePath);
return fetch(url);
}
}

View file

@ -21,6 +21,7 @@ import './embeddedWorkbenchLoader.css';
import { Workbench } from './workbench';
import { currentTheme, toggleTheme } from '@web/theme';
import type { SourceLocation } from './modelUtil';
import { filePathToTraceURL } from './uiModeTraceView';
function openPage(url: string, target?: string) {
if (url)
@ -40,7 +41,15 @@ export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
React.useEffect(() => {
window.addEventListener('message', async ({ data: { method, params } }) => {
if (method === 'loadTraceRequested') {
setTraceURLs(params.traceUrl ? [params.traceUrl] : []);
if (params.traceUrl) {
// the param is called URL, but VS Code sends a path
const url = params.traceUrl.startsWith('http')
? params.traceUrl
: filePathToTraceURL(params.traceUrl).toString();
setTraceURLs([url]);
} else {
setTraceURLs([]);
}
setProcessingErrorMessage(null);
} else if (method === 'applyTheme') {
if (currentTheme() !== params.theme)

View file

@ -55,7 +55,7 @@ export const TraceView: React.FC<{
// Test finished.
const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path) {
loadSingleTraceFile(new URL(attachment.path, 'file://')).then(model => setModel({ model, isLive: false }));
loadSingleTraceFile(filePathToTraceURL(attachment.path)).then(model => setModel({ model, isLive: false }));
return;
}
@ -73,7 +73,7 @@ export const TraceView: React.FC<{
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(new URL(traceLocation, 'file://'));
const model = await loadSingleTraceFile(filePathToTraceURL(traceLocation));
setModel({ model, isLive: true });
} catch {
setModel(undefined);
@ -109,24 +109,25 @@ const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefi
return undefined;
};
async function loadSingleTraceFile(tracePathOrURL: URL): Promise<MultiTraceModel> {
async function loadSingleTraceFile(traceURL: URL): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', formatUrl(tracePathOrURL).toString());
params.set('trace', formatUrl(traceURL).toString());
params.set('limit', '1');
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}
function formatUrl(tracePathOrURL: URL) {
if (tracePathOrURL.protocol === 'file:') {
const url = new URL('/trace/file', testServerBaseURL);
url.searchParams.set('path', tracePathOrURL.pathname);
return url;
}
function formatUrl(traceURL: URL) {
// Dropbox does not support cors.
if (traceURL.hostname === 'dropbox.com')
traceURL.hostname = 'dl.dropboxusercontent.com';
if (tracePathOrURL.hostname === 'dropbox.com')
tracePathOrURL.hostname = 'dl.dropboxusercontent.com';
return tracePathOrURL;
return traceURL;
}
export function filePathToTraceURL(path: string) {
const url = new URL('file', testServerBaseURL);
url.searchParams.set('path', path);
return url;
}

View file

@ -14,11 +14,16 @@
limitations under the License.
*/
export function ansi2html(text: string): string {
export function ansi2html(text: string, defaultColors?: { bg: string, fg: string }): string {
const regex = /(\x1b\[(\d+(;\d+)*)m)|([^\x1b]+)/g;
const tokens: string[] = [];
let match;
let style: any = {};
let reverse = false;
let fg: string | undefined = defaultColors?.fg;
let bg: string | undefined = defaultColors?.bg;
while ((match = regex.exec(text)) !== null) {
const [, , codeStr, , text] = match;
if (codeStr) {
@ -29,11 +34,28 @@ export function ansi2html(text: string): string {
case 2: style['opacity'] = '0.8'; break;
case 3: style['font-style'] = 'italic'; break;
case 4: style['text-decoration'] = 'underline'; break;
case 7:
reverse = true;
break;
case 8: style.display = 'none'; break;
case 9: style['text-decoration'] = 'line-through'; break;
case 22: style = { ...style, 'font-weight': undefined, 'font-style': undefined, 'opacity': undefined, 'text-decoration': undefined }; break;
case 23: style = { ...style, 'font-weight': undefined, 'font-style': undefined, 'opacity': undefined }; break;
case 24: style = { ...style, 'text-decoration': undefined }; break;
case 22:
delete style['font-weight'];
delete style['font-style'];
delete style['opacity'];
delete style['text-decoration'];
break;
case 23:
delete style['font-weight'];
delete style['font-style'];
delete style['opacity'];
break;
case 24:
delete style['text-decoration'];
break;
case 27:
reverse = false;
break;
case 30:
case 31:
case 32:
@ -41,8 +63,12 @@ export function ansi2html(text: string): string {
case 34:
case 35:
case 36:
case 37: style.color = ansiColors[code - 30]; break;
case 39: style = { ...style, color: undefined }; break;
case 37:
fg = ansiColors[code - 30];
break;
case 39:
fg = defaultColors?.fg;
break;
case 40:
case 41:
case 42:
@ -50,8 +76,12 @@ export function ansi2html(text: string): string {
case 44:
case 45:
case 46:
case 47: style['background-color'] = ansiColors[code - 40]; break;
case 49: style = { ...style, 'background-color': undefined }; break;
case 47:
bg = ansiColors[code - 40];
break;
case 49:
bg = defaultColors?.bg;
break;
case 53: style['text-decoration'] = 'overline'; break;
case 90:
case 91:
@ -60,7 +90,9 @@ export function ansi2html(text: string): string {
case 94:
case 95:
case 96:
case 97: style.color = brightAnsiColors[code - 90]; break;
case 97:
fg = brightAnsiColors[code - 90];
break;
case 100:
case 101:
case 102:
@ -68,10 +100,19 @@ export function ansi2html(text: string): string {
case 104:
case 105:
case 106:
case 107: style['background-color'] = brightAnsiColors[code - 100]; break;
case 107:
bg = brightAnsiColors[code - 100];
break;
}
} else if (text) {
tokens.push(`<span style="${styleBody(style)}">${escapeHTML(text)}</span>`);
const styleCopy = { ...style };
const color = reverse ? bg : fg;
if (color !== undefined)
styleCopy['color'] = color;
const backgroundColor = reverse ? fg : bg;
if (backgroundColor !== undefined)
styleCopy['background-color'] = backgroundColor;
tokens.push(`<span style="${styleBody(styleCopy)}">${escapeHTML(text)}</span>`);
}
}
return tokens.join('');

View file

@ -24,14 +24,20 @@ export const Expandable: React.FunctionComponent<React.PropsWithChildren<{
expanded: boolean,
expandOnTitleClick?: boolean,
}>> = ({ title, children, setExpanded, expanded, expandOnTitleClick }) => {
const id = React.useId();
return <div className={clsx('expandable', expanded && 'expanded')}>
<div className='expandable-title' onClick={() => expandOnTitleClick && setExpanded(!expanded)}>
<div
role='button'
aria-expanded={expanded}
aria-controls={id}
className='expandable-title'
onClick={() => expandOnTitleClick && setExpanded(!expanded)}>
<div
className={clsx('codicon', expanded ? 'codicon-chevron-down' : 'codicon-chevron-right')}
style={{ cursor: 'pointer', color: 'var(--vscode-foreground)', marginLeft: '5px' }}
onClick={() => !expandOnTitleClick && setExpanded(!expanded)} />
{title}
</div>
{ expanded && <div style={{ marginLeft: 25 }}>{children}</div> }
{ expanded && <div id={id} role='region' style={{ marginLeft: 25 }}>{children}</div> }
</div>;
};

View file

@ -231,6 +231,7 @@ export function TreeItemHeader<T extends TreeItem>({
icon,
isKeyboardNavigation,
setIsKeyboardNavigation }: TreeItemHeaderProps<T>) {
const groupId = React.useId();
const itemRef = React.useRef(null);
React.useEffect(() => {
@ -251,7 +252,7 @@ export function TreeItemHeader<T extends TreeItem>({
const titled = title?.(item);
const iconed = icon?.(item) || 'codicon-blank';
return <div ref={itemRef} role='treeitem' aria-selected={item === selectedItem} aria-expanded={expanded} title={titled} className='vbox' style={{ flex: 'none' }}>
return <div ref={itemRef} role='treeitem' aria-selected={item === selectedItem} aria-expanded={expanded} aria-controls={groupId} title={titled} className='vbox' style={{ flex: 'none' }}>
<div
onDoubleClick={() => onAccepted?.(item)}
className={clsx(
@ -281,7 +282,7 @@ export function TreeItemHeader<T extends TreeItem>({
{icon && <div className={'codicon ' + iconed} style={{ minWidth: 16, marginRight: 4 }} aria-label={'[' + iconed.replace('codicon', 'icon') + ']'}></div>}
{typeof rendered === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{rendered}</div> : rendered}
</div>
{!!children.length && <div role='group'>
{!!children.length && <div id={groupId} role='group'>
{children.map(child => {
const itemData = treeItems.get(child);
return itemData && <TreeItemHeader

View file

@ -21,6 +21,11 @@ import net from 'net';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy';
import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy';
// Certain browsers perform telemetry requests which we want to ignore.
const kConnectHostsToIgnore = new Set([
'www.bing.com:443',
]);
export class TestProxy {
readonly PORT: number;
readonly URL: string;
@ -63,6 +68,8 @@ export class TestProxy {
this._prependHandler('connect', (req: IncomingMessage) => {
if (!options?.allowConnectRequests)
return;
if (kConnectHostsToIgnore.has(req.url))
return;
this.connectHosts.push(req.url);
req.url = `127.0.0.1:${port}`;
});

View file

@ -15,33 +15,26 @@
* limitations under the License.
*/
import { browserTest as base, expect } from '../config/browserTest';
import { browserTest as it, expect } from '../config/browserTest';
const it = base.extend<{ isChromiumHeadedLike: boolean }>({
isChromiumHeadedLike: async ({ browserName, headless }, use) => {
const isChromiumHeadedLike = browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW);
await use(isChromiumHeadedLike);
},
});
it('should fail without credentials', async ({ browser, server, isChromiumHeadedLike }) => {
it('should fail without credentials', async ({ browser, server, browserName, channel }) => {
server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext();
const page = await context.newPage();
const responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e);
if (isChromiumHeadedLike)
if (browserName === 'chromium' && channel !== 'chromium-headless-shell')
expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else
expect(responseOrError.status()).toBe(401);
});
it('should work with setHTTPCredentials', async ({ browser, server, isChromiumHeadedLike }) => {
it('should work with setHTTPCredentials', async ({ browser, server, browserName, channel }) => {
server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext();
const page = await context.newPage();
let responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e);
if (isChromiumHeadedLike)
if (browserName === 'chromium' && channel !== 'chromium-headless-shell')
expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else
expect(responseOrError.status()).toBe(401);
@ -109,21 +102,21 @@ it('should work with correct credentials and matching origin case insensitive',
await context.close();
});
it('should fail with correct credentials and mismatching scheme', async ({ browser, server, isChromiumHeadedLike }) => {
it('should fail with correct credentials and mismatching scheme', async ({ browser, server, browserName, channel }) => {
server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext({
httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') }
});
const page = await context.newPage();
const responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e);
if (isChromiumHeadedLike)
if (browserName === 'chromium' && channel !== 'chromium-headless-shell')
expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else
expect(responseOrError.status()).toBe(401);
await context.close();
});
it('should fail with correct credentials and mismatching hostname', async ({ browser, server, isChromiumHeadedLike }) => {
it('should fail with correct credentials and mismatching hostname', async ({ browser, server, browserName, channel }) => {
server.setAuth('/empty.html', 'user', 'pass');
const hostname = new URL(server.PREFIX).hostname;
const origin = server.PREFIX.replace(hostname, 'mismatching-hostname');
@ -132,14 +125,14 @@ it('should fail with correct credentials and mismatching hostname', async ({ bro
});
const page = await context.newPage();
const responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e);
if (isChromiumHeadedLike)
if (browserName === 'chromium' && channel !== 'chromium-headless-shell')
expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else
expect(responseOrError.status()).toBe(401);
await context.close();
});
it('should fail with correct credentials and mismatching port', async ({ browser, server, isChromiumHeadedLike }) => {
it('should fail with correct credentials and mismatching port', async ({ browser, server, browserName, channel }) => {
server.setAuth('/empty.html', 'user', 'pass');
const origin = server.PREFIX.replace(server.PORT.toString(), (server.PORT + 1).toString());
const context = await browser.newContext({
@ -147,7 +140,7 @@ it('should fail with correct credentials and mismatching port', async ({ browser
});
const page = await context.newPage();
const responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e);
if (isChromiumHeadedLike)
if (browserName === 'chromium' && channel !== 'chromium-headless-shell')
expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else
expect(responseOrError.status()).toBe(401);

View file

@ -169,7 +169,8 @@ for (const kind of ['launchServer', 'run-server'] as const) {
await browser.close();
});
test('should ignore page.pause when headed', async ({ connect, startRemoteServer, browserType }) => {
test('should ignore page.pause when headed', async ({ connect, startRemoteServer, browserType, channel }) => {
test.skip(channel === 'chromium-headless-shell', 'Headless Shell does not support headed mode');
const headless = (browserType as any)._defaultLaunchOptions.headless;
(browserType as any)._defaultLaunchOptions.headless = false;
const remoteServer = await startRemoteServer(kind);

View file

@ -239,7 +239,7 @@ it('should not generate dispatchers for subresources w/o listeners', async ({ pa
});
});
it('should work with the domain module', async ({ browserType, server, browserName }) => {
it('should work with the domain module', async ({ browserType, server, browserName, channel }) => {
const local = domain.create();
local.run(() => { });
let err;
@ -262,7 +262,7 @@ it('should work with the domain module', async ({ browserType, server, browserNa
if (browserName === 'firefox')
expect(message).toBe('CLOSE_ABNORMAL');
else
expect(message).toContain(': 400');
expect(message).toContain(channel?.includes('msedge') ? '' : ': 400');
await browser.close();

View file

@ -232,7 +232,8 @@ it('should click a button when it overlays oopif', async function({ page, browse
expect(await page.evaluate(() => (window as any)['BUTTON_CLICKED'])).toBe(true);
});
it('should report google.com frame with headed', async ({ browserType, server }) => {
it('should report google.com frame with headed', async ({ browserType, server, channel }) => {
it.skip(channel === 'chromium-headless-shell', 'Headless Shell does not support headed mode');
// @see https://github.com/GoogleChrome/puppeteer/issues/2548
// https://google.com is isolated by default in Chromium embedder.
const browser = await browserType.launch({ headless: false });

View file

@ -253,3 +253,29 @@ test('should reset routes before reuse', async ({ server, connectedBrowserFactor
await expect(page2).toHaveTitle('console.log test');
await browser2.close();
});
test('should highlight inside iframe', async ({ backend, connectedBrowser }, testInfo) => {
testInfo.annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33146' });
const context = await connectedBrowser._newContextForReuse();
const page = await context.newPage();
await backend.navigate({ url: `data:text/html,<div>bar</div><iframe srcdoc="<div>bar</div>"/>` });
await page.frameLocator('iframe').getByText('bar').highlight();
const highlight = page.frameLocator('iframe').locator('x-pw-highlight');
await expect(highlight).not.toHaveCount(0);
await backend.hideHighlight();
await expect(highlight).toHaveCount(0);
await backend.highlight({ selector: `frameLocator('iframe').getByText('bar')` });
await expect(highlight).not.toHaveCount(0);
await backend.highlight({ selector: `frameLocator('iframe').frameLocator('iframe').getByText('bar')` });
await expect(highlight).toHaveCount(0);
await backend.highlight({ selector: `getByText('bar')` });
await expect(highlight).toHaveCount(1);
await expect(page.locator('x-pw-highlight')).toHaveCount(1);
});

View file

@ -636,8 +636,8 @@ it('should be able to download a inline PDF file via response interception', asy
await page.close();
});
it('should be able to download a inline PDF file via navigation', async ({ browser, server, asset, browserName, headless }) => {
it.fixme(((!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW) && browserName === 'chromium'));
it('should be able to download a inline PDF file via navigation', async ({ browser, server, asset, browserName, channel }) => {
it.skip(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'We expect PDF Viewer to open up in Chromium');
const page = await browser.newPage();
await page.goto(server.EMPTY_PAGE);
await page.setContent(`

View file

@ -101,9 +101,10 @@ it('should change document.activeElement', async ({ page, server }) => {
expect(active).toEqual(['INPUT', 'TEXTAREA']);
});
it('should not affect screenshots', async ({ page, server, browserName, headless, isWindows }) => {
it('should not affect screenshots', async ({ page, server, browserName, headless, isWindows, channel }) => {
it.skip(browserName === 'webkit' && isWindows && !headless, 'WebKit/Windows/headed has a larger minimal viewport. See https://github.com/microsoft/playwright/issues/22616');
it.skip(browserName === 'firefox' && !headless, 'Firefox headed produces a different image');
it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'https://github.com/microsoft/playwright/issues/33330');
const page2 = await page.context().newPage();
await Promise.all([

View file

@ -39,4 +39,24 @@ test.describe(() => {
await expect.poll(() =>
recorder.text('C#')).toContain(`await Expect(page.GetByRole(AriaRole.Button)).ToMatchAriaSnapshotAsync("- button \\"Submit\\"");`);
});
test('should generate regex in aria snapshot', async ({ openRecorder }) => {
const { recorder } = await openRecorder();
await recorder.setContentAndWait(`<main><button>Submit 123</button></main>`);
await recorder.page.click('x-pw-tool-item.snapshot');
await recorder.page.hover('button');
await recorder.trustedClick();
await expect.poll(() =>
recorder.text('JavaScript')).toContain(`await expect(page.getByRole('button')).toMatchAriaSnapshot(\`- button /Submit \\\\d+/\`);`);
await expect.poll(() =>
recorder.text('Python')).toContain(`expect(page.get_by_role("button")).to_match_aria_snapshot("- button /Submit \\\\d+/")`);
await expect.poll(() =>
recorder.text('Python Async')).toContain(`await expect(page.get_by_role(\"button\")).to_match_aria_snapshot("- button /Submit \\\\d+/")`);
await expect.poll(() =>
recorder.text('Java')).toContain(`assertThat(page.getByRole(AriaRole.BUTTON)).matchesAriaSnapshot("- button /Submit \\\\d+/");`);
await expect.poll(() =>
recorder.text('C#')).toContain(`await Expect(page.GetByRole(AriaRole.Button)).ToMatchAriaSnapshotAsync("- button /Submit \\\\d+/");`);
});
});

View file

@ -14,6 +14,7 @@
* limitations under the License.
*/
import { hostPlatform } from '../../packages/playwright-core/src/utils/hostPlatform';
import { browserTest as it, expect } from '../config/browserTest';
import fs from 'fs';
import os from 'os';
@ -33,6 +34,7 @@ async function checkFeatures(name: string, context: any, server: any) {
it('Safari Desktop', async ({ browser, browserName, platform, server, headless }) => {
it.skip(browserName !== 'webkit');
it.skip(browserName === 'webkit' && platform === 'darwin' && os.arch() === 'x64', 'Modernizr uses WebGL which is not available on Intel macOS - https://bugs.webkit.org/show_bug.cgi?id=278277');
it.skip(browserName === 'webkit' && hostPlatform.startsWith('ubuntu20.04'), 'Ubuntu 20.04 is frozen');
const context = await browser.newContext({
deviceScaleFactor: 2
});
@ -52,7 +54,6 @@ it('Safari Desktop', async ({ browser, browserName, platform, server, headless }
actual.video = !!actual.video;
if (platform === 'linux') {
expected.subpixelfont = false;
expected.speechrecognition = false;
expected.publickeycredential = false;
expected.mediastream = false;
@ -96,6 +97,7 @@ it('Safari Desktop', async ({ browser, browserName, platform, server, headless }
it('Mobile Safari', async ({ playwright, browser, browserName, platform, server, headless }) => {
it.skip(browserName !== 'webkit');
it.skip(browserName === 'webkit' && platform === 'darwin' && os.arch() === 'x64', 'Modernizr uses WebGL which is not available on Intel macOS - https://bugs.webkit.org/show_bug.cgi?id=278277');
it.skip(browserName === 'webkit' && hostPlatform.startsWith('ubuntu20.04'), 'Ubuntu 20.04 is frozen');
const iPhone = playwright.devices['iPhone 12'];
const context = await browser.newContext(iPhone);
const { actual, expected } = await checkFeatures('mobile-safari-18', context, server);
@ -119,7 +121,6 @@ it('Mobile Safari', async ({ playwright, browser, browserName, platform, server,
}
if (platform === 'linux') {
expected.subpixelfont = false;
expected.speechrecognition = false;
expected.publickeycredential = false;
expected.mediastream = false;

View file

@ -145,20 +145,19 @@ it.describe('permissions', () => {
});
});
it('should support clipboard read', async ({ page, context, server, browserName, isWindows, isLinux, headless }) => {
it('should support clipboard read', async ({ page, context, server, browserName, isWindows, isLinux, headless, channel }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27475' });
it.fail(browserName === 'firefox', 'No such permissions (requires flag) in Firefox');
it.fixme(browserName === 'webkit' && isWindows, 'WebPasteboardProxy::allPasteboardItemInfo not implemented for Windows.');
it.fixme(browserName === 'webkit' && isLinux && headless, 'WebPasteboardProxy::allPasteboardItemInfo not implemented for WPE.');
const isChromiumHeadedLike = browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW);
await page.goto(server.EMPTY_PAGE);
// There is no 'clipboard-read' permission in WebKit Web API.
if (browserName !== 'webkit')
expect(await getPermission(page, 'clipboard-read')).toBe('prompt');
if (!isChromiumHeadedLike) {
// Headed Chromium shows a dialog and does not resolve the promise.
if (browserName === 'chromium' && channel === 'chromium-headless-shell') {
// Chromium shows a dialog and does not resolve the promise.
const error = await page.evaluate(() => navigator.clipboard.readText()).catch(e => e);
expect(error.toString()).toContain('denied');
}

View file

@ -126,13 +126,7 @@ for (const browserName of browserNames) {
metadata: {
platform: process.platform,
docker: !!process.env.INSIDE_DOCKER,
headless: (() => {
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
return 'headless-new';
if (headed)
return 'headed';
return 'headless';
})(),
headless: headed ? 'headed' : 'headless',
browserName,
channel,
mode,

View file

@ -22,7 +22,8 @@ import { verifyViewport } from '../config/utils';
browserTest.describe('page screenshot', () => {
browserTest.skip(({ browserName, headless }) => browserName === 'firefox' && !headless, 'Firefox headed produces a different image.');
browserTest('should run in parallel in multiple pages', async ({ server, contextFactory }) => {
browserTest('should run in parallel in multiple pages', async ({ server, contextFactory, browserName, channel }) => {
browserTest.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'https://github.com/microsoft/playwright/issues/33330');
const context = await contextFactory();
const N = 5;
const pages = await Promise.all(Array(N).fill(0).map(async () => {

View file

@ -407,9 +407,9 @@ for (const params of [
height: 768,
}
]) {
browserTest(`should produce screencast frames ${params.id}`, async ({ video, contextFactory, browserName, platform, headless }, testInfo) => {
browserTest(`should produce screencast frames ${params.id}`, async ({ video, contextFactory, browserName, platform, headless, channel }, testInfo) => {
browserTest.skip(browserName === 'chromium' && video === 'on', 'Same screencast resolution conflicts');
browserTest.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW), 'Chromium screencast on headed has a min width issue');
browserTest.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'Chromium screencast has a min width issue');
browserTest.fixme(params.id === 'fit' && browserName === 'chromium' && platform === 'darwin', 'High DPI maxes image at 600x600');
browserTest.fixme(params.id === 'fit' && browserName === 'webkit' && platform === 'linux', 'Image size is flaky');
browserTest.fixme(browserName === 'firefox' && !headless, 'Image size is different');

View file

@ -473,9 +473,9 @@ it.describe('screencast', () => {
expect(videoFiles.length).toBe(2);
});
it('should scale frames down to the requested size ', async ({ browser, browserName, server, headless, trace }, testInfo) => {
const isChromiumHeadlessNew = browserName === 'chromium' && !!headless && !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW;
it.fixme(!headless || isChromiumHeadlessNew, 'Fails on headed');
it('should scale frames down to the requested size ', async ({ browser, browserName, server, headless, channel }, testInfo) => {
it.fixme(!headless, 'Fails on headed');
it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'Fails on Chromiums');
const context = await browser.newContext({
recordVideo: {
@ -722,9 +722,9 @@ it.describe('screencast', () => {
expect(files.length).toBe(1);
});
it('should capture full viewport', async ({ browserType, browserName, headless, isWindows }, testInfo) => {
it('should capture full viewport', async ({ browserType, browserName, isWindows, channel }, testInfo) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22411' });
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW), 'The square is not on the video');
it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'The square is not on the video');
it.fixme(browserName === 'firefox' && isWindows, 'https://github.com/microsoft/playwright/issues/14405');
const size = { width: 600, height: 400 };
const browser = await browserType.launch();
@ -757,9 +757,9 @@ it.describe('screencast', () => {
expectAll(pixels, almostRed);
});
it('should capture full viewport on hidpi', async ({ browserType, browserName, headless, isWindows, isLinux }, testInfo) => {
it('should capture full viewport on hidpi', async ({ browserType, browserName, headless, isWindows, isLinux, channel }, testInfo) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22411' });
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW), 'The square is not on the video');
it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'The square is not on the video');
it.fixme(browserName === 'firefox' && isWindows, 'https://github.com/microsoft/playwright/issues/14405');
it.fixme(browserName === 'webkit' && isLinux && !headless, 'https://github.com/microsoft/playwright/issues/22617');
const size = { width: 600, height: 400 };
@ -794,9 +794,10 @@ it.describe('screencast', () => {
expectAll(pixels, almostRed);
});
it('should work with video+trace', async ({ browser, trace, headless }, testInfo) => {
it('should work with video+trace', async ({ browser, trace, headless, browserName, channel }, testInfo) => {
it.skip(trace === 'on');
it.fixme(!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW, 'different trace screencast image size on all browsers');
it.fixme(!headless, 'different trace screencast image size on all browsers');
it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'different trace screencast image size on Chromium');
const size = { width: 500, height: 400 };
const traceFile = testInfo.outputPath('trace.zip');

View file

@ -137,7 +137,7 @@ it('should emit binary frame events', async ({ page, server }) => {
expect(sent[1][i]).toBe(i);
});
it('should emit error', async ({ page, server, browserName }) => {
it('should emit error', async ({ page, server, browserName, channel }) => {
let callback;
const result = new Promise(f => callback = f);
page.on('websocket', ws => ws.on('socketerror', callback));
@ -148,7 +148,7 @@ it('should emit error', async ({ page, server, browserName }) => {
if (browserName === 'firefox')
expect(message).toBe('CLOSE_ABNORMAL');
else
expect(message).toContain(': 400');
expect(message).toContain(channel?.includes('msedge') ? '' : ': 400');
});
it('should not have stray error events', async ({ page, server }) => {

View file

@ -64,8 +64,8 @@ it('should snapshot list with accessible name', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- list "my list":
- listitem: "one"
- listitem: "two"
- listitem: one
- listitem: two
`);
});
@ -92,7 +92,7 @@ it('should allow text nodes', async ({ page }) => {
await checkAndMatchSnapshot(page.locator('body'), `
- heading "Microsoft" [level=1]
- text: "Open source projects and samples from Microsoft"
- text: Open source projects and samples from Microsoft
`);
});
@ -105,7 +105,7 @@ it('should snapshot details visibility', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- group: "Summary"
- group: Summary
`);
});
@ -145,10 +145,10 @@ it('should snapshot integration', async ({ page }) => {
await checkAndMatchSnapshot(page.locator('body'), `
- heading "Microsoft" [level=1]
- text: "Open source projects and samples from Microsoft"
- text: Open source projects and samples from Microsoft
- list:
- listitem:
- group: "Verified"
- group: Verified
- listitem:
- link "Sponsor"
`);
@ -164,7 +164,7 @@ it('should support multiline text', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- paragraph: "Line 1 Line 2 Line 3"
- paragraph: Line 1 Line 2 Line 3
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- paragraph: |
@ -180,7 +180,7 @@ it('should concatenate span text', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- text: "One Two Three"
- text: One Two Three
`);
});
@ -190,7 +190,7 @@ it('should concatenate span text 2', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- text: "One Two Three"
- text: One Two Three
`);
});
@ -200,7 +200,7 @@ it('should concatenate div text with spaces', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- text: "One Two Three"
- text: One Two Three
`);
});
@ -362,12 +362,12 @@ it('should snapshot inner text', async ({ page }) => {
await checkAndMatchSnapshot(page.locator('body'), `
- listitem:
- text: "a.test.ts"
- text: a.test.ts
- button "Run"
- button "Show source"
- button "Watch"
- listitem:
- text: "snapshot 30ms"
- text: snapshot 30ms
- button "Run"
- button "Show source"
- button "Watch"
@ -382,12 +382,11 @@ it('should include pseudo codepoints', async ({ page, server }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- paragraph: "\ueab2hello"
- paragraph: \ueab2hello
`);
});
it('check aria-hidden text', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
it('check aria-hidden text', async ({ page }) => {
await page.setContent(`
<p>
<span>hello</span>
@ -396,12 +395,11 @@ it('check aria-hidden text', async ({ page, server }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- paragraph: "hello"
- paragraph: hello
`);
});
it('should ignore presentation and none roles', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
it('should ignore presentation and none roles', async ({ page }) => {
await page.setContent(`
<ul>
<li role='presentation'>hello</li>
@ -410,6 +408,54 @@ it('should ignore presentation and none roles', async ({ page, server }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- list: "hello world"
- list: hello world
`);
});
it('should treat input value as text in templates', async ({ page }) => {
await page.setContent(`
<input value='hello world'>
`);
await checkAndMatchSnapshot(page.locator('body'), `
- textbox: hello world
`);
});
it('should respect aria-owns', async ({ page }) => {
await page.setContent(`
<a href='about:blank' aria-owns='input p'>
<div role='region'>Link 1</div>
</a>
<a href='about:blank' aria-owns='input p'>
<div role='region'>Link 2</div>
</a>
<input id='input' value='Value'>
<p id='p'>Paragraph</p>
`);
// - Different from Chrome DevTools which attributes ownership to the last element.
// - CDT also does not include non-owned children in accessible name.
// - Disregarding these as aria-owns can't suggest multiple parts by spec.
await checkAndMatchSnapshot(page.locator('body'), `
- link "Link 1 Value Paragraph":
- region: Link 1
- textbox: Value
- paragraph: Paragraph
- link "Link 2 Value Paragraph":
- region: Link 2
`);
});
it('should be ok with circular ownership', async ({ page }) => {
await page.setContent(`
<a href='about:blank' id='parent'>
<div role='region' aria-owns='parent'>Hello</div>
</a>
`);
await checkAndMatchSnapshot(page.locator('body'), `
- link "Hello":
- region: Hello
`);
});

View file

@ -15,6 +15,7 @@
* limitations under the License.
*/
import { stripAnsi } from 'tests/config/utils';
import type { TestServer } from '../config/testserver';
import { test as it, expect } from './pageTest';
@ -139,3 +140,21 @@ it('should report navigation in the log when clicking anchor', async ({ page, se
expect(error.message).toContain('waiting for scheduled navigations to finish');
expect(error.message).toContain(`navigated to "${server.PREFIX + '/frames/one-frame.html'}"`);
});
it('should report and collapse log in action', async ({ page, server, mode }) => {
await page.setContent(`<input id='checkbox' type='checkbox' style="visibility: hidden"></input>`);
const error = await page.locator('input').click({ timeout: 5000 }).catch(e => e);
const message = stripAnsi(error.message);
expect(message).toContain(`Call log:`);
expect(message).toMatch(/\d+ × waiting for/);
const logLines = message.substring(message.indexOf('Call log:')).split('\n');
expect(logLines.length).toBeLessThan(30);
});
it('should report and collapse log in expect', async ({ page, server, mode }) => {
await page.setContent(`<input id='checkbox' type='checkbox' style="visibility: hidden"></input>`);
const error = await expect(page.locator('input')).toBeVisible({ timeout: 5000 }).catch(e => e);
const message = stripAnsi(error.message);
expect(message).toContain(`Call log:`);
expect(message).toMatch(/\d+ × locator resolved to/);
});

View file

@ -119,9 +119,8 @@ it('clicking checkbox should activate it', async ({ page, browserName, headless,
it('tab should cycle between single input and browser', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' }
}, async ({ page, browserName, headless }) => {
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW),
'Chromium in headful mode keeps input focused.');
}, async ({ page, browserName, channel }) => {
it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'Chromium keeps input focused.');
it.fixme(browserName !== 'chromium');
await page.setContent(`<label for="input1">input1</label>
<input id="input1">
@ -147,9 +146,8 @@ it('tab should cycle between single input and browser', {
it('tab should cycle between document elements and browser', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' }
}, async ({ page, browserName, headless }) => {
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW),
'Chromium in headful mode keeps last input focused.');
}, async ({ page, browserName, channel }) => {
it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'Chromium keeps last input focused.');
it.fixme(browserName !== 'chromium');
await page.setContent(`
<input id="input1">

View file

@ -396,8 +396,8 @@ test('expected formatter', async ({ page }) => {
expect(stripAnsi(error.message)).toContain(`
Locator: locator('body')
- Expected - 2
+ Received string + 3
- Expected - 2
+ Received + 3
- - heading "todos"
- - textbox "Wrong text"
@ -405,3 +405,56 @@ Locator: locator('body')
+ - heading "todos" [level=1]
+ - textbox "What needs to be done?"`);
});
test('should unpack escaped names', async ({ page }) => {
{
await page.setContent(`
<button>Click: me</button>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- 'button "Click: me"'
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- 'button /Click: me/'
`);
}
{
await page.setContent(`
<button>Click / me</button>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- button "Click / me"
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- button /Click \\/ me/
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- 'button /Click \\/ me/'
`);
}
{
await page.setContent(`
<button>Click \\ me</button>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- button "Click \\ me"
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- button /Click \\\\ me/
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- 'button /Click \\\\ me/'
`);
}
{
await page.setContent(`
<button>Click ' me</button>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- 'button "Click '' me"'
`);
}
});

View file

@ -0,0 +1,32 @@
/**
* 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 { fitToWidth } from 'packages/playwright/lib/reporters/base';
import { test, expect } from './playwright-test-fixtures';
test('chinese characters', () => {
expect(fitToWidth('你你好', 3)).toBe('…好');
expect(fitToWidth('你好你好', 4)).toBe('…好');
});
test('surrogate pairs', () => {
expect(fitToWidth('🫣🤗', 2)).toBe('…');
expect(fitToWidth('🫣🤗', 3)).toBe('…🤗');
expect(fitToWidth('🚄🚄', 1)).toBe('…');
expect(fitToWidth('🚄🚄', 2)).toBe('…');
expect(fitToWidth('🚄🚄', 3)).toBe('…🚄');
expect(fitToWidth('🚄🚄', 4)).toBe('🚄🚄');
});

View file

@ -472,7 +472,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport();
await page.click('text=fails');
await expect(page.locator('.test-error-view span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)');
await expect(page.locator('.test-error-view span:has-text("true")').first()).toHaveCSS('color', 'rgb(205, 49, 49)');
});
test('should show trace source', async ({ runInlineTest, page, showReport }) => {
@ -939,8 +939,9 @@ for (const useIntermediateMergeReport of [true, false] as const) {
expect(result.exitCode).toBe(1);
await showReport();
await page.click('text="is a test"');
const stricken = await page.locator('css=strike').innerText();
expect(stricken).toBe('old');
await expect(page.locator('.test-error-view').getByText('old')).toHaveCSS('text-decoration', 'line-through solid rgb(205, 49, 49)');
await expect(page.locator('.test-error-view').getByText('new', { exact: true })).toHaveCSS('text-decoration', 'none solid rgb(0, 188, 0)');
});
test('should strikethrough textual diff with commonalities', async ({ runInlineTest, showReport, page }) => {
@ -966,8 +967,32 @@ for (const useIntermediateMergeReport of [true, false] as const) {
expect(result.exitCode).toBe(1);
await showReport();
await page.click('text="is a test"');
const stricken = await page.locator('css=strike').innerText();
expect(stricken).toBe('old');
await expect(page.locator('.test-error-view').getByText('old')).toHaveCSS('text-decoration', 'line-through solid rgb(205, 49, 49)');
await expect(page.locator('.test-error-view').getByText('new', { exact: true })).toHaveCSS('text-decoration', 'none solid rgb(0, 188, 0)');
await expect(page.locator('.test-error-view').getByText('common Expected:')).toHaveCSS('text-decoration', 'none solid rgb(36, 41, 47)');
});
test('should highlight inline textual diff in toHaveText', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('is a test', async ({ page }) => {
await page.setContent('<div>begin inner end</div>');
await expect(page.locator('div')).toHaveText('inner', { timeout: 500 });
});
`
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
expect(result.exitCode).toBe(1);
await showReport();
await page.click('text="is a test"');
await expect(page.locator('.test-error-view').getByText('begin ', { exact: true })).toHaveCSS('color', 'rgb(246, 248, 250)');
await expect(page.locator('.test-error-view').getByText('begin ', { exact: true })).toHaveCSS('background-color', 'rgb(205, 49, 49)');
await expect(page.locator('.test-error-view').getByText('inner', { exact: true })).toHaveCSS('color', 'rgb(205, 49, 49)');
await expect(page.locator('.test-error-view').getByText('inner', { exact: true })).toHaveCSS('background-color', 'rgb(246, 248, 250)');
await expect(page.locator('.test-error-view').getByText('end ', { exact: true })).toHaveCSS('color', 'rgb(246, 248, 250)');
await expect(page.locator('.test-error-view').getByText('end ', { exact: true })).toHaveCSS('background-color', 'rgb(205, 49, 49)');
});
test('should differentiate repeat-each test cases', async ({ runInlineTest, showReport, page }) => {
@ -984,13 +1009,13 @@ for (const useIntermediateMergeReport of [true, false] as const) {
expect(result.exitCode).toBe(1);
await showReport();
await page.locator('text=sample').first().click();
await expect(page.locator('text=ouch')).toHaveCount(1);
await page.locator('text=All').first().click();
await page.getByText('sample').first().click();
await expect(page.getByText('ouch')).toHaveCount(2);
await page.getByText('All').first().click();
await page.locator('text=sample').nth(1).click();
await expect(page.locator('text=Before Hooks')).toBeVisible();
await expect(page.locator('text=ouch')).toBeHidden();
await page.getByText('sample').nth(1).click();
await expect(page.getByText('Before Hooks')).toBeVisible();
await expect(page.getByText('ouch')).toBeHidden();
});
test('should group similar / loop steps', async ({ runInlineTest, showReport, page }) => {

View file

@ -5,15 +5,15 @@
"packages": {
"": {
"dependencies": {
"@playwright/test": "1.49.0-alpha-2024-10-26"
"@playwright/test": "1.49.0-alpha-2024-10-30"
}
},
"node_modules/@playwright/test": {
"version": "1.49.0-alpha-2024-10-26",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0-alpha-2024-10-26.tgz",
"integrity": "sha512-EUl8wIsAWVJJlX2ynKdY1KxRs44Yz9MPDmN8AH6HIdwazSRe1ML46kaM3V49gQvMVMo5JZfuXnRzbtYDMFpKYA==",
"version": "1.49.0-alpha-2024-10-30",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0-alpha-2024-10-30.tgz",
"integrity": "sha512-7pq4a+eDCkp6VmGGpr6KanL0gQ2SunC9dAjtP+VZLobdaY0ZL7XkmD2rL8UNANF2AkmKdOf+GmTS+wZ42qhvLg==",
"dependencies": {
"playwright": "1.49.0-alpha-2024-10-26"
"playwright": "1.49.0-alpha-2024-10-30"
},
"bin": {
"playwright": "cli.js"
@ -36,11 +36,11 @@
}
},
"node_modules/playwright": {
"version": "1.49.0-alpha-2024-10-26",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0-alpha-2024-10-26.tgz",
"integrity": "sha512-1qh/6z4UdWv7qMocNQmUMbvZAXzzS93jckUzjGr0mWMn9rs4QavHhuK0s2HIS0hLB+t5T1+NBUpHudWzeasudA==",
"version": "1.49.0-alpha-2024-10-30",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0-alpha-2024-10-30.tgz",
"integrity": "sha512-OJ++0IaaTyBHZuPMi7kNZ/ssyRvN4Fkh7NCpYBRyfPL8H90bEVwDe7j4Ab79HMBLxUZMg7D7aRIlimmYmVdbpQ==",
"dependencies": {
"playwright-core": "1.49.0-alpha-2024-10-26"
"playwright-core": "1.49.0-alpha-2024-10-30"
},
"bin": {
"playwright": "cli.js"
@ -53,9 +53,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.49.0-alpha-2024-10-26",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0-alpha-2024-10-26.tgz",
"integrity": "sha512-ELIdRRHkdzkHP7siPcFSE9jBLRnDHE1l3UigIgEzVN9o34yGBgH8TAkC2uK1M8Jrkomc3jKQm5faiBsimu0XEQ==",
"version": "1.49.0-alpha-2024-10-30",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0-alpha-2024-10-30.tgz",
"integrity": "sha512-T1KDI5SQPqzVIahMOpCX7GE2Slv/5KEM+gSnj5mQZDi57Z8Ij5xnGz6ZX4KBdDrmkBRHLrRM4ijXfH1Q7zNkEg==",
"bin": {
"playwright-core": "cli.js"
},
@ -66,11 +66,11 @@
},
"dependencies": {
"@playwright/test": {
"version": "1.49.0-alpha-2024-10-26",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0-alpha-2024-10-26.tgz",
"integrity": "sha512-EUl8wIsAWVJJlX2ynKdY1KxRs44Yz9MPDmN8AH6HIdwazSRe1ML46kaM3V49gQvMVMo5JZfuXnRzbtYDMFpKYA==",
"version": "1.49.0-alpha-2024-10-30",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0-alpha-2024-10-30.tgz",
"integrity": "sha512-7pq4a+eDCkp6VmGGpr6KanL0gQ2SunC9dAjtP+VZLobdaY0ZL7XkmD2rL8UNANF2AkmKdOf+GmTS+wZ42qhvLg==",
"requires": {
"playwright": "1.49.0-alpha-2024-10-26"
"playwright": "1.49.0-alpha-2024-10-30"
}
},
"fsevents": {
@ -80,18 +80,18 @@
"optional": true
},
"playwright": {
"version": "1.49.0-alpha-2024-10-26",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0-alpha-2024-10-26.tgz",
"integrity": "sha512-1qh/6z4UdWv7qMocNQmUMbvZAXzzS93jckUzjGr0mWMn9rs4QavHhuK0s2HIS0hLB+t5T1+NBUpHudWzeasudA==",
"version": "1.49.0-alpha-2024-10-30",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0-alpha-2024-10-30.tgz",
"integrity": "sha512-OJ++0IaaTyBHZuPMi7kNZ/ssyRvN4Fkh7NCpYBRyfPL8H90bEVwDe7j4Ab79HMBLxUZMg7D7aRIlimmYmVdbpQ==",
"requires": {
"fsevents": "2.3.2",
"playwright-core": "1.49.0-alpha-2024-10-26"
"playwright-core": "1.49.0-alpha-2024-10-30"
}
},
"playwright-core": {
"version": "1.49.0-alpha-2024-10-26",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0-alpha-2024-10-26.tgz",
"integrity": "sha512-ELIdRRHkdzkHP7siPcFSE9jBLRnDHE1l3UigIgEzVN9o34yGBgH8TAkC2uK1M8Jrkomc3jKQm5faiBsimu0XEQ=="
"version": "1.49.0-alpha-2024-10-30",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0-alpha-2024-10-30.tgz",
"integrity": "sha512-T1KDI5SQPqzVIahMOpCX7GE2Slv/5KEM+gSnj5mQZDi57Z8Ij5xnGz6ZX4KBdDrmkBRHLrRM4ijXfH1Q7zNkEg=="
}
}
}

View file

@ -1,6 +1,6 @@
{
"private": true,
"dependencies": {
"@playwright/test": "1.49.0-alpha-2024-10-26"
"@playwright/test": "1.49.0-alpha-2024-10-30"
}
}

View file

@ -145,6 +145,17 @@ test('should format console messages in page', async ({ runUITest }, testInfo) =
'Failed to load resource: net::ERR_CONNECTION_REFUSED',
]);
await expect(page.locator('.console-tab')).toMatchAriaSnapshot(`
- list:
- listitem: "/<anonymous>:1 Object {a: 1}/"
- listitem: "/<anonymous>:4 Date/"
- listitem: "/<anonymous>:5 Regex \/a\//"
- listitem: "/<anonymous>:6 Number 0 one 2/"
- listitem: "/<anonymous>:7 Download the React DevTools for a better development experience: https:\/\/fb\.me\/react-devtools/"
- listitem: "/<anonymous>:8 Array of values/"
- listitem: "/Failed to load resource: net::ERR_CONNECTION_REFUSED/"
`);
const label = page.getByText('React DevTools');
await expect(label).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(label).toHaveCSS('font-weight', '700');

View file

@ -15,7 +15,8 @@
*/
import * as fs from 'fs';
import { test, expect } from './playwright-test-fixtures';
import { test, expect, playwrightCtConfigText } from './playwright-test-fixtures';
import { execSync } from 'child_process';
test.describe.configure({ mode: 'parallel' });
@ -47,6 +48,10 @@ test('should update snapshot with the update-snapshots flag', async ({ runInline
});
`);
execSync(`patch -p1 < ${patchPath}`, { cwd: testInfo.outputPath() });
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});
test('should update missing snapshots', async ({ runInlineTest }, testInfo) => {
@ -76,4 +81,250 @@ test('should update missing snapshots', async ({ runInlineTest }, testInfo) => {
});
`);
execSync(`patch -p1 < ${patchPath}`, { cwd: testInfo.outputPath() });
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});
test('should generate baseline with regex', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Time 15:30</li>
<li>Year 2022</li>
<li>Duration 12ms</li>
<li>22,333</li>
<li>2,333.79</li>
<li>Total 22</li>
<li>/Regex 1/</li>
<li>/Regex 22ms/</li>
</ul>\`);
await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
});
`
});
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
const data = fs.readFileSync(patchPath, 'utf-8');
expect(data).toBe(`--- a/a.spec.ts
+++ b/a.spec.ts
@@ -13,6 +13,18 @@
<li>/Regex 1/</li>
<li>/Regex 22ms/</li>
</ul>\`);
- await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
+ - list:
+ - listitem: Item 1
+ - listitem: Item 2
+ - listitem: /Time \\\\d+:\\\\d+/
+ - listitem: /Year \\\\d+/
+ - listitem: /Duration \\\\d+[hmsp]+/
+ - listitem: /\\\\d+,\\\\d+/
+ - listitem: /\\\\d+,\\\\d+\\\\.\\\\d+/
+ - listitem: /Total \\\\d+/
+ - listitem: /Regex 1/
+ - listitem: /\\\\/Regex \\\\d+[hmsp]+\\\\//
+ \`);
});
`);
execSync(`patch -p1 < ${patchPath}`, { cwd: testInfo.outputPath() });
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});
test('should generate baseline with special characters', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<ul>
<button>Click: me</button>
<button>Click: 123</button>
<button>Click ' me</button>
<button>Click: ' me</button>
<li>Item: 1</li>
<li>Item {a: b}</li>
</ul>\`);
await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
});
`
});
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
const data = fs.readFileSync(patchPath, 'utf-8');
expect(data).toBe(`--- a/a.spec.ts
+++ b/a.spec.ts
@@ -9,6 +9,14 @@
<li>Item: 1</li>
<li>Item {a: b}</li>
</ul>\`);
- await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
+ - list:
+ - 'button "Click: me"'
+ - 'button /Click: \\\\d+/'
+ - button "Click ' me"
+ - 'button "Click: '' me"'
+ - listitem: \"Item: 1\"
+ - listitem: \"Item {a: b}\"
+ \`);
});
`);
execSync(`patch -p1 < ${patchPath}`, { cwd: testInfo.outputPath() });
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});
test('should update missing snapshots in tsx', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightCtConfigText,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = () => <button>Button</button>;
`,
'src/button.test.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './button.tsx';
test('pass', async ({ mount }) => {
const component = await mount(<Button></Button>);
await expect(component).toMatchAriaSnapshot(\`\`);
});
`,
});
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
const data = fs.readFileSync(patchPath, 'utf-8');
expect(data).toBe(`--- a/src/button.test.tsx
+++ b/src/button.test.tsx
@@ -4,6 +4,8 @@
test('pass', async ({ mount }) => {
const component = await mount(<Button></Button>);
- await expect(component).toMatchAriaSnapshot(\`\`);
+ await expect(component).toMatchAriaSnapshot(\`
+ - button \"Button\"
+ \`);
});
`);
execSync(`patch -p1 < ${patchPath}`, { cwd: testInfo.outputPath() });
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});
test('should update multiple files', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': playwrightCtConfigText,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = () => <button>Button</button>;
`,
'src/button-1.test.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './button.tsx';
test('pass 1', async ({ mount }) => {
const component = await mount(<Button></Button>);
await expect(component).toMatchAriaSnapshot(\`\`);
});
`,
'src/button-2.test.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './button.tsx';
test('pass 2', async ({ mount }) => {
const component = await mount(<Button></Button>);
await expect(component).toMatchAriaSnapshot(\`\`);
});
`,
});
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
const data = fs.readFileSync(patchPath, 'utf-8');
expect(data).toBe(`--- a/src/button-1.test.tsx
+++ b/src/button-1.test.tsx
@@ -4,6 +4,8 @@
test('pass 1', async ({ mount }) => {
const component = await mount(<Button></Button>);
- await expect(component).toMatchAriaSnapshot(\`\`);
+ await expect(component).toMatchAriaSnapshot(\`
+ - button \"Button\"
+ \`);
});
--- a/src/button-2.test.tsx
+++ b/src/button-2.test.tsx
@@ -4,6 +4,8 @@
test('pass 2', async ({ mount }) => {
const component = await mount(<Button></Button>);
- await expect(component).toMatchAriaSnapshot(\`\`);
+ await expect(component).toMatchAriaSnapshot(\`
+ - button \"Button\"
+ \`);
});
`);
execSync(`patch -p1 < ${patchPath}`, { cwd: testInfo.outputPath() });
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});
test('should generate baseline for input values', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<input value="hello world">\`);
await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
});
`
});
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
const data = fs.readFileSync(patchPath, 'utf-8');
expect(data).toBe(`--- a/a.spec.ts
+++ b/a.spec.ts
@@ -2,6 +2,8 @@
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<input value="hello world">\`);
- await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
+ - textbox: hello world
+ \`);
});
`);
execSync(`patch -p1 < ${patchPath}`, { cwd: testInfo.outputPath() });
const result2 = await runInlineTest({});
expect(result2.exitCode).toBe(0);
});

View file

@ -4,7 +4,7 @@ set -x
trap "cd $(pwd -P)" EXIT
SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)"
NODE_VERSION="20.18.0" # autogenerated via ./update-playwright-driver-version.mjs
NODE_VERSION="22.11.0" # autogenerated via ./update-playwright-driver-version.mjs
cd "$(dirname "$0")"
PACKAGE_VERSION=$(node -p "require('../../package.json').version")

View file

@ -14,7 +14,7 @@ RUN apt-get update && \
apt-get install -y curl wget gpg ca-certificates && \
mkdir -p /etc/apt/keyrings && \
curl -sL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" >> /etc/apt/sources.list.d/nodesource.list && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" >> /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y nodejs && \
# Feature-parity with node.js base images.

View file

@ -14,7 +14,7 @@ RUN apt-get update && \
apt-get install -y curl wget gpg ca-certificates && \
mkdir -p /etc/apt/keyrings && \
curl -sL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" >> /etc/apt/sources.list.d/nodesource.list && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" >> /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y nodejs && \
# Feature-parity with node.js base images.