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: | run: |
npx playwright merge-reports --config .github/workflows/merge.config.ts ./all-blob-reports npx playwright merge-reports --config .github/workflows/merge.config.ts ./all-blob-reports
env: env:
NODE_OPTIONS: --max-old-space-size=4096 NODE_OPTIONS: --max-old-space-size=8192
- name: Azure Login - name: Azure Login
uses: azure/login@v2 uses: azure/login@v2

View file

@ -268,23 +268,6 @@ jobs:
- run: npx playwright install-deps - run: npx playwright install-deps
- run: utils/build/build-playwright-driver.sh - 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: test_linux_chromium_headless_shell:
name: Chromium Headless Shell name: Chromium Headless Shell
environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }}

View file

@ -1,6 +1,6 @@
# 🎭 Playwright # 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-131.0.6778.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) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr
| | Linux | macOS | Windows | | | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: | | :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->131.0.6778.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: | | WebKit <!-- GEN:webkit-version -->18.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->131.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox <!-- GEN:firefox-version -->131.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

View file

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

View file

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

View file

@ -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. 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 ```py title="test_my_application.py"
pytest --slowmo 100 from playwright.sync_api import Page, BrowserContext
from pytest_playwright.pytest_playwright import CreateContextCallback
def test_foo(page: Page, new_context: CreateContextCallback) -> None:
page.goto("https://example.com")
context = new_context()
page2 = context.new_page()
# page and page2 are in different contexts
``` ```
Slows down Playwright operations by 100 milliseconds.
### Skip test by browser ### Skip test by browser
```py title="test_my_application.py" ```py title="test_my_application.py"
@ -198,7 +203,7 @@ def browser_context_args(browser_context_args):
} }
``` ```
### Device emulation ### Device emulation / BrowserContext option overrides
```py title="conftest.py" ```py title="conftest.py"
import pytest import pytest

27
package-lock.json generated
View file

@ -2412,28 +2412,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true "dev": true
}, },
"node_modules/ansi-to-html": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
"dependencies": {
"entities": "^2.2.0"
},
"bin": {
"ansi-to-html": "bin/ansi-to-html"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ansi-to-html/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/anymatch": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@ -7895,10 +7873,7 @@
} }
}, },
"packages/html-reporter": { "packages/html-reporter": {
"version": "0.0.0", "version": "0.0.0"
"dependencies": {
"ansi-to-html": "^0.7.2"
}
}, },
"packages/playwright": { "packages/playwright": {
"version": "1.49.0-next", "version": "1.49.0-next",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,8 @@ import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/re
import { metadataToCallLog } from './recorder/recorderUtils'; import { metadataToCallLog } from './recorder/recorderUtils';
import type * as actions from '@recorder/actions'; import type * as actions from '@recorder/actions';
import { buildFullSelector } from '../utils/isomorphic/recorderUtils'; import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
import { stringifySelector } from '../utils/isomorphic/selectorParser';
import type { Frame } from './frames';
const recorderSymbol = Symbol('recorderSymbol'); const recorderSymbol = Symbol('recorderSymbol');
@ -146,12 +148,12 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._pushAllSources(); this._pushAllSources();
}); });
await this._context.exposeBinding('__pw_recorderState', false, source => { await this._context.exposeBinding('__pw_recorderState', false, async source => {
let actionSelector = ''; let actionSelector = '';
let actionPoint: Point | undefined; let actionPoint: Point | undefined;
const hasActiveScreenshotCommand = [...this._currentCallsMetadata.keys()].some(isScreenshotCommand); const hasActiveScreenshotCommand = [...this._currentCallsMetadata.keys()].some(isScreenshotCommand);
if (!hasActiveScreenshotCommand) { if (!hasActiveScreenshotCommand) {
actionSelector = this._highlightedSelector; actionSelector = await this._scopeHighlightedSelectorToFrame(source.frame);
for (const [metadata, sdkObject] of this._currentCallsMetadata) { for (const [metadata, sdkObject] of this._currentCallsMetadata) {
if (source.page === sdkObject.attribution.page) { if (source.page === sdkObject.attribution.page) {
actionPoint = metadata.point || actionPoint; actionPoint = metadata.point || actionPoint;
@ -243,13 +245,38 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._refreshOverlay(); 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) { setOutput(codegenId: string, outputFile: string | undefined) {
this._contextRecorder.setOutput(codegenId, outputFile); this._contextRecorder.setOutput(codegenId, outputFile);
} }
private _refreshOverlay() { private _refreshOverlay() {
for (const page of this._context.pages()) for (const page of this._context.pages()) {
page.mainFrame().evaluateExpression('window.__pw_refreshOverlay()').catch(() => {}); for (const frame of page.frames())
frame.evaluateExpression('window.__pw_refreshOverlay()').catch(() => {});
}
} }
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,6 +23,8 @@ import { EXPECTED_COLOR } from '../common/expectBundle';
import { callLogText } from '../util'; import { callLogText } from '../util';
import { printReceivedStringContainExpectedSubstring } from './expect'; import { printReceivedStringContainExpectedSubstring } from './expect';
import { currentTestInfo } from '../common/globals'; import { currentTestInfo } from '../common/globals';
import type { MatcherReceived } from '@injected/ariaSnapshot';
import { escapeTemplateString } from 'playwright-core/lib/utils';
export async function toMatchAriaSnapshot( export async function toMatchAriaSnapshot(
this: ExpectMatcherState, this: ExpectMatcherState,
@ -70,28 +72,38 @@ export async function toMatchAriaSnapshot(
const timeout = options.timeout ?? this.timeout; const timeout = options.timeout ?? this.timeout;
expected = unshift(expected); expected = unshift(expected);
const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout }); const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout });
const typedReceived = received as MatcherReceived | typeof kNoElementsFoundError;
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError; const notFound = typedReceived === kNoElementsFoundError;
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 escapedExpected = escapePrivateUsePoints(expected);
const escapedReceived = escapePrivateUsePoints(received); const escapedReceived = escapePrivateUsePoints(typedReceived.raw);
const message = () => { const message = () => {
if (pass) { if (pass) {
if (notFound) if (notFound)
return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log); return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
const printedReceived = printReceivedStringContainExpectedSubstring(escapedReceived, escapedReceived.indexOf(escapedExpected), escapedExpected.length); const printedReceived = printReceivedStringContainExpectedSubstring(escapedReceived, escapedReceived.indexOf(escapedExpected), escapedExpected.length);
return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived string: ${printedReceived}` + callLogText(log); return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${printedReceived}` + callLogText(log);
} else { } else {
const labelExpected = `Expected`; const labelExpected = `Expected`;
if (notFound) if (notFound)
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log); return messagePrefix + `${labelExpected}: ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
return messagePrefix + this.utils.printDiffOrStringify(escapedExpected, escapedReceived, labelExpected, 'Received string', false) + callLogText(log); return messagePrefix + this.utils.printDiffOrStringify(escapedExpected, escapedReceived, labelExpected, 'Received', false) + callLogText(log);
} }
}; };
if (!this.isNot && pass === this.isNot && generateNewBaseline) { if (!this.isNot && pass === this.isNot && generateNewBaseline) {
// Only rebaseline failed snapshots. // 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 }; return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline };
} }
@ -107,7 +119,7 @@ export async function toMatchAriaSnapshot(
} }
function escapePrivateUsePoints(str: string) { function escapePrivateUsePoints(str: string) {
return str.replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`); return escapeTemplateString(str).replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
} }
function unshift(snapshot: string): string { function unshift(snapshot: string): string {

View file

@ -18,6 +18,7 @@ import { colors as realColors, ms as milliseconds, parseStackTraceLine } from 'p
import path from 'path'; import path from 'path';
import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter'; import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter';
import { getPackageManagerExecCommand } from 'playwright-core/lib/utils'; import { getPackageManagerExecCommand } from 'playwright-core/lib/utils';
import { getEastAsianWidth } from '../utilsBundle';
import type { ReporterV2 } from './reporterV2'; import type { ReporterV2 } from './reporterV2';
import { resolveReporterOutputPath } from '../util'; import { resolveReporterOutputPath } from '../util';
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' }; export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
@ -490,11 +491,35 @@ export function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, ''); return str.replace(ansiRegex, '');
} }
function characterWidth(c: string) {
return getEastAsianWidth.eastAsianWidth(c.codePointAt(0)!);
}
function stringWidth(v: string) {
let width = 0;
for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(v))
width += characterWidth(segment);
return width;
}
function suffixOfWidth(v: string, width: number) {
const segments = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(v)];
let suffixBegin = v.length;
for (const { segment, index } of segments.reverse()) {
const segmentWidth = stringWidth(segment);
if (segmentWidth > width)
break;
width -= segmentWidth;
suffixBegin = index;
}
return v.substring(suffixBegin);
}
// Leaves enough space for the "prefix" to also fit. // Leaves enough space for the "prefix" to also fit.
function fitToWidth(line: string, width: number, prefix?: string): string { export function fitToWidth(line: string, width: number, prefix?: string): string {
const prefixLength = prefix ? stripAnsiEscapes(prefix).length : 0; const prefixLength = prefix ? stripAnsiEscapes(prefix).length : 0;
width -= prefixLength; width -= prefixLength;
if (line.length <= width) if (stringWidth(line) <= width)
return line; return line;
// Even items are plain text, odd items are control sequences. // Even items are plain text, odd items are control sequences.
@ -505,13 +530,14 @@ function fitToWidth(line: string, width: number, prefix?: string): string {
// Include all control sequences to preserve formatting. // Include all control sequences to preserve formatting.
taken.push(parts[i]); taken.push(parts[i]);
} else { } else {
let part = parts[i].substring(parts[i].length - width); let part = suffixOfWidth(parts[i], width);
if (part.length < parts[i].length && part.length > 0) { const wasTruncated = part.length < parts[i].length;
if (wasTruncated && parts[i].length > 0) {
// Add ellipsis if we are truncating. // Add ellipsis if we are truncating.
part = '\u2026' + part.substring(1); part = '\u2026' + suffixOfWidth(parts[i], width - 1);
} }
taken.push(part); taken.push(part);
width -= part.length; width -= stringWidth(part);
} }
} }
return taken.reverse().join(''); return taken.reverse().join('');

View file

@ -17,9 +17,10 @@
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import type { T } from '../transform/babelBundle'; 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 { MultiMap } from 'playwright-core/lib/utils';
import { generateUnifiedDiff } 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 type { FullConfigInternal } from '../common/config';
import { filterProjects } from './projectUtils'; import { filterProjects } from './projectUtils';
const t: typeof T = types; const t: typeof T = types;
@ -45,15 +46,20 @@ export function addSuggestedRebaseline(location: Location, suggestedRebaseline:
export async function applySuggestedRebaselines(config: FullConfigInternal) { export async function applySuggestedRebaselines(config: FullConfigInternal) {
if (config.config.updateSnapshots !== 'all' && config.config.updateSnapshots !== 'missing') if (config.config.updateSnapshots !== 'all' && config.config.updateSnapshots !== 'missing')
return; return;
if (!suggestedRebaselines.size)
return;
const [project] = filterProjects(config.projects, config.cliProjectFilter); const [project] = filterProjects(config.projects, config.cliProjectFilter);
if (!project) if (!project)
return; 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 source = await fs.promises.readFile(fileName, 'utf8');
const lines = source.split('\n'); const lines = source.split('\n');
const replacements = suggestedRebaselines.get(fileName); 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 }[] = []; const ranges: { start: number, end: number, oldText: string, newText: string }[] = [];
traverse(fileNode, { traverse(fileNode, {
@ -75,7 +81,7 @@ export async function applySuggestedRebaselines(config: FullConfigInternal) {
if (matcher.loc!.start.column + 1 !== replacement.location.column) if (matcher.loc!.start.column + 1 !== replacement.location.column)
continue; continue;
const indent = lines[matcher.loc!.start.line - 1].match(/^\s*/)![0]; 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 }); 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); result = result.substring(0, range.start) + range.newText + result.substring(range.end);
const relativeName = path.relative(process.cwd(), fileName); const relativeName = path.relative(process.cwd(), fileName);
files.push(relativeName);
patches.push(generateUnifiedDiff(source, result, relativeName.replace(/\\/g, '/')));
}
const patchFile = path.join(project.project.outputDir, 'rebaselines.patch'); const patchFile = path.join(project.project.outputDir, 'rebaselines.patch');
await fs.promises.mkdir(path.dirname(patchFile), { recursive: true }); await fs.promises.mkdir(path.dirname(patchFile), { recursive: true });
await fs.promises.writeFile(patchFile, generateUnifiedDiff(source, result, relativeName)); 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. * limitations under the License.
*/ */
import type { BabelFileResult } from '../../bundles/babel/node_modules/@types/babel__core'; import type { BabelFileResult, ParseResult } from '../../bundles/babel/node_modules/@types/babel__core';
export const codeFrameColumns: typeof import('../../bundles/babel/node_modules/@types/babel__code-frame').codeFrameColumns = require('./babelBundleImpl').codeFrameColumns; export const codeFrameColumns: typeof import('../../bundles/babel/node_modules/@types/babel__code-frame').codeFrameColumns = require('./babelBundleImpl').codeFrameColumns;
export const declare: typeof import('../../bundles/babel/node_modules/@types/babel__helper-plugin-utils').declare = require('./babelBundleImpl').declare; export const declare: typeof import('../../bundles/babel/node_modules/@types/babel__helper-plugin-utils').declare = require('./babelBundleImpl').declare;
export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types; export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types;
export const parse: typeof import('../../bundles/babel/node_modules/@babel/parser/typings/babel-parser').parse = require('./babelBundleImpl').parse;
export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse; export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse;
export type BabelPlugin = [string, any?]; export type BabelPlugin = [string, any?];
export type BabelTransformFunction = (code: string, filename: string, isTypeScript: boolean, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult; export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult;
export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform; export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform;
export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult;
export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse;
export type { NodePath, types as T, PluginObj } from '../../bundles/babel/node_modules/@types/babel__core'; export type { NodePath, types as T, PluginObj } from '../../bundles/babel/node_modules/@types/babel__core';
export type { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils'; export type { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils';

View file

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

View file

@ -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 stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable;
export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer; export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer;
export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar; export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar;
export const getEastAsianWidth: typeof import('../bundles/utils/node_modules/get-east-asian-width') = require('./utilsBundleImpl').getEastAsianWidth;
export type { RawSourceMap } from '../bundles/utils/node_modules/source-map'; 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) { constructor(traceURL: string, progress: Progress) {
this._traceURL = traceURL; this._traceURL = traceURL;
zipjs.configure({ baseURL: self.location.href } as any);
this._zipReader = new zipjs.ZipReader( this._zipReader = new zipjs.ZipReader(
new zipjs.HttpReader(traceURL, { mode: 'cors', preventHeadRequest: true } as any), new zipjs.HttpReader(traceURL, { mode: 'cors', preventHeadRequest: true } as any),
{ useWebWorkers: false }); { useWebWorkers: false });
@ -82,19 +81,16 @@ export class ZipTraceModelBackend implements TraceModelBackend {
} }
export class FetchTraceModelBackend implements TraceModelBackend { export class FetchTraceModelBackend implements TraceModelBackend {
private _entriesPromise: Promise<Map<string, URL>>; private _entriesPromise: Promise<Map<string, string>>;
private _traceURL: string; private _traceURL: string;
constructor(traceURL: string) { constructor(traceURL: string) {
this._traceURL = traceURL; this._traceURL = traceURL;
this._entriesPromise = fetch(traceURL).then(async response => { this._entriesPromise = fetch(traceURL).then(async response => {
const json = JSON.parse(await response.text()); const json = await response.json();
const entries = new Map<string, URL>(); const entries = new Map<string, string>();
for (const entry of json.entries) { for (const entry of json.entries)
const entryURL = new URL(traceURL); entries.set(entry.name, entry.path);
entryURL.searchParams.set('path', entry.path);
entries.set(entry.name, entryURL);
}
return entries; return entries;
}); });
} }
@ -129,9 +125,12 @@ export class FetchTraceModelBackend implements TraceModelBackend {
private async _readEntry(entryName: string): Promise<Response | undefined> { private async _readEntry(entryName: string): Promise<Response | undefined> {
const entries = await this._entriesPromise; const entries = await this._entriesPromise;
const fileURL = entries.get(entryName); const filePath = entries.get(entryName);
if (!fileURL) if (!filePath)
return; 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 { Workbench } from './workbench';
import { currentTheme, toggleTheme } from '@web/theme'; import { currentTheme, toggleTheme } from '@web/theme';
import type { SourceLocation } from './modelUtil'; import type { SourceLocation } from './modelUtil';
import { filePathToTraceURL } from './uiModeTraceView';
function openPage(url: string, target?: string) { function openPage(url: string, target?: string) {
if (url) if (url)
@ -40,7 +41,15 @@ export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
React.useEffect(() => { React.useEffect(() => {
window.addEventListener('message', async ({ data: { method, params } }) => { window.addEventListener('message', async ({ data: { method, params } }) => {
if (method === 'loadTraceRequested') { 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); setProcessingErrorMessage(null);
} else if (method === 'applyTheme') { } else if (method === 'applyTheme') {
if (currentTheme() !== params.theme) if (currentTheme() !== params.theme)

View file

@ -55,7 +55,7 @@ export const TraceView: React.FC<{
// Test finished. // Test finished.
const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace'); const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path) { 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; return;
} }
@ -73,7 +73,7 @@ export const TraceView: React.FC<{
// Start polling running test. // Start polling running test.
pollTimer.current = setTimeout(async () => { pollTimer.current = setTimeout(async () => {
try { try {
const model = await loadSingleTraceFile(new URL(traceLocation, 'file://')); const model = await loadSingleTraceFile(filePathToTraceURL(traceLocation));
setModel({ model, isLive: true }); setModel({ model, isLive: true });
} catch { } catch {
setModel(undefined); setModel(undefined);
@ -109,24 +109,25 @@ const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefi
return undefined; return undefined;
}; };
async function loadSingleTraceFile(tracePathOrURL: URL): Promise<MultiTraceModel> { async function loadSingleTraceFile(traceURL: URL): Promise<MultiTraceModel> {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('trace', formatUrl(tracePathOrURL).toString()); params.set('trace', formatUrl(traceURL).toString());
params.set('limit', '1'); params.set('limit', '1');
const response = await fetch(`contexts?${params.toString()}`); const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[]; const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries); return new MultiTraceModel(contextEntries);
} }
function formatUrl(tracePathOrURL: URL) { function formatUrl(traceURL: URL) {
if (tracePathOrURL.protocol === 'file:') { // Dropbox does not support cors.
const url = new URL('/trace/file', testServerBaseURL); if (traceURL.hostname === 'dropbox.com')
url.searchParams.set('path', tracePathOrURL.pathname); traceURL.hostname = 'dl.dropboxusercontent.com';
return url;
}
if (tracePathOrURL.hostname === 'dropbox.com') return traceURL;
tracePathOrURL.hostname = 'dl.dropboxusercontent.com'; }
return tracePathOrURL; 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. 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 regex = /(\x1b\[(\d+(;\d+)*)m)|([^\x1b]+)/g;
const tokens: string[] = []; const tokens: string[] = [];
let match; let match;
let style: any = {}; let style: any = {};
let reverse = false;
let fg: string | undefined = defaultColors?.fg;
let bg: string | undefined = defaultColors?.bg;
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {
const [, , codeStr, , text] = match; const [, , codeStr, , text] = match;
if (codeStr) { if (codeStr) {
@ -29,11 +34,28 @@ export function ansi2html(text: string): string {
case 2: style['opacity'] = '0.8'; break; case 2: style['opacity'] = '0.8'; break;
case 3: style['font-style'] = 'italic'; break; case 3: style['font-style'] = 'italic'; break;
case 4: style['text-decoration'] = 'underline'; break; case 4: style['text-decoration'] = 'underline'; break;
case 7:
reverse = true;
break;
case 8: style.display = 'none'; break; case 8: style.display = 'none'; break;
case 9: style['text-decoration'] = 'line-through'; 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 22:
case 23: style = { ...style, 'font-weight': undefined, 'font-style': undefined, 'opacity': undefined }; break; delete style['font-weight'];
case 24: style = { ...style, 'text-decoration': undefined }; break; 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 30:
case 31: case 31:
case 32: case 32:
@ -41,8 +63,12 @@ export function ansi2html(text: string): string {
case 34: case 34:
case 35: case 35:
case 36: case 36:
case 37: style.color = ansiColors[code - 30]; break; case 37:
case 39: style = { ...style, color: undefined }; break; fg = ansiColors[code - 30];
break;
case 39:
fg = defaultColors?.fg;
break;
case 40: case 40:
case 41: case 41:
case 42: case 42:
@ -50,8 +76,12 @@ export function ansi2html(text: string): string {
case 44: case 44:
case 45: case 45:
case 46: case 46:
case 47: style['background-color'] = ansiColors[code - 40]; break; case 47:
case 49: style = { ...style, 'background-color': undefined }; break; bg = ansiColors[code - 40];
break;
case 49:
bg = defaultColors?.bg;
break;
case 53: style['text-decoration'] = 'overline'; break; case 53: style['text-decoration'] = 'overline'; break;
case 90: case 90:
case 91: case 91:
@ -60,7 +90,9 @@ export function ansi2html(text: string): string {
case 94: case 94:
case 95: case 95:
case 96: case 96:
case 97: style.color = brightAnsiColors[code - 90]; break; case 97:
fg = brightAnsiColors[code - 90];
break;
case 100: case 100:
case 101: case 101:
case 102: case 102:
@ -68,10 +100,19 @@ export function ansi2html(text: string): string {
case 104: case 104:
case 105: case 105:
case 106: case 106:
case 107: style['background-color'] = brightAnsiColors[code - 100]; break; case 107:
bg = brightAnsiColors[code - 100];
break;
} }
} else if (text) { } 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(''); return tokens.join('');

View file

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

View file

@ -231,6 +231,7 @@ export function TreeItemHeader<T extends TreeItem>({
icon, icon,
isKeyboardNavigation, isKeyboardNavigation,
setIsKeyboardNavigation }: TreeItemHeaderProps<T>) { setIsKeyboardNavigation }: TreeItemHeaderProps<T>) {
const groupId = React.useId();
const itemRef = React.useRef(null); const itemRef = React.useRef(null);
React.useEffect(() => { React.useEffect(() => {
@ -251,7 +252,7 @@ export function TreeItemHeader<T extends TreeItem>({
const titled = title?.(item); const titled = title?.(item);
const iconed = icon?.(item) || 'codicon-blank'; 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 <div
onDoubleClick={() => onAccepted?.(item)} onDoubleClick={() => onAccepted?.(item)}
className={clsx( 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>} {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} {typeof rendered === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{rendered}</div> : rendered}
</div> </div>
{!!children.length && <div role='group'> {!!children.length && <div id={groupId} role='group'>
{children.map(child => { {children.map(child => {
const itemData = treeItems.get(child); const itemData = treeItems.get(child);
return itemData && <TreeItemHeader 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 type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy';
import { SocksProxy } from '../../packages/playwright-core/lib/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 { export class TestProxy {
readonly PORT: number; readonly PORT: number;
readonly URL: string; readonly URL: string;
@ -63,6 +68,8 @@ export class TestProxy {
this._prependHandler('connect', (req: IncomingMessage) => { this._prependHandler('connect', (req: IncomingMessage) => {
if (!options?.allowConnectRequests) if (!options?.allowConnectRequests)
return; return;
if (kConnectHostsToIgnore.has(req.url))
return;
this.connectHosts.push(req.url); this.connectHosts.push(req.url);
req.url = `127.0.0.1:${port}`; req.url = `127.0.0.1:${port}`;
}); });

View file

@ -15,33 +15,26 @@
* limitations under the License. * 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 }>({ it('should fail without credentials', async ({ browser, server, browserName, channel }) => {
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 }) => {
server.setAuth('/empty.html', 'user', 'pass'); server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
const responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e); 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'); expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else else
expect(responseOrError.status()).toBe(401); 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'); server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
let responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e); 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'); expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else else
expect(responseOrError.status()).toBe(401); expect(responseOrError.status()).toBe(401);
@ -109,21 +102,21 @@ it('should work with correct credentials and matching origin case insensitive',
await context.close(); 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'); server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext({ const context = await browser.newContext({
httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') } httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') }
}); });
const page = await context.newPage(); const page = await context.newPage();
const responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e); 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'); expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else else
expect(responseOrError.status()).toBe(401); expect(responseOrError.status()).toBe(401);
await context.close(); 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'); server.setAuth('/empty.html', 'user', 'pass');
const hostname = new URL(server.PREFIX).hostname; const hostname = new URL(server.PREFIX).hostname;
const origin = server.PREFIX.replace(hostname, 'mismatching-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 page = await context.newPage();
const responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e); 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'); expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else else
expect(responseOrError.status()).toBe(401); expect(responseOrError.status()).toBe(401);
await context.close(); 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'); server.setAuth('/empty.html', 'user', 'pass');
const origin = server.PREFIX.replace(server.PORT.toString(), (server.PORT + 1).toString()); const origin = server.PREFIX.replace(server.PORT.toString(), (server.PORT + 1).toString());
const context = await browser.newContext({ 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 page = await context.newPage();
const responseOrError = await page.goto(server.EMPTY_PAGE).catch(e => e); 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'); expect(responseOrError.message).toContain('net::ERR_INVALID_AUTH_CREDENTIALS');
else else
expect(responseOrError.status()).toBe(401); expect(responseOrError.status()).toBe(401);

View file

@ -169,7 +169,8 @@ for (const kind of ['launchServer', 'run-server'] as const) {
await browser.close(); 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; const headless = (browserType as any)._defaultLaunchOptions.headless;
(browserType as any)._defaultLaunchOptions.headless = false; (browserType as any)._defaultLaunchOptions.headless = false;
const remoteServer = await startRemoteServer(kind); 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(); const local = domain.create();
local.run(() => { }); local.run(() => { });
let err; let err;
@ -262,7 +262,7 @@ it('should work with the domain module', async ({ browserType, server, browserNa
if (browserName === 'firefox') if (browserName === 'firefox')
expect(message).toBe('CLOSE_ABNORMAL'); expect(message).toBe('CLOSE_ABNORMAL');
else else
expect(message).toContain(': 400'); expect(message).toContain(channel?.includes('msedge') ? '' : ': 400');
await browser.close(); 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); 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 // @see https://github.com/GoogleChrome/puppeteer/issues/2548
// https://google.com is isolated by default in Chromium embedder. // https://google.com is isolated by default in Chromium embedder.
const browser = await browserType.launch({ headless: false }); 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 expect(page2).toHaveTitle('console.log test');
await browser2.close(); 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(); await page.close();
}); });
it('should be able to download a inline PDF file via navigation', async ({ browser, server, asset, browserName, headless }) => { it('should be able to download a inline PDF file via navigation', async ({ browser, server, asset, browserName, channel }) => {
it.fixme(((!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW) && browserName === 'chromium')); it.skip(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'We expect PDF Viewer to open up in Chromium');
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
await page.setContent(` await page.setContent(`

View file

@ -101,9 +101,10 @@ it('should change document.activeElement', async ({ page, server }) => {
expect(active).toEqual(['INPUT', 'TEXTAREA']); 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 === '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.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(); const page2 = await page.context().newPage();
await Promise.all([ await Promise.all([

View file

@ -39,4 +39,24 @@ test.describe(() => {
await expect.poll(() => await expect.poll(() =>
recorder.text('C#')).toContain(`await Expect(page.GetByRole(AriaRole.Button)).ToMatchAriaSnapshotAsync("- button \\"Submit\\"");`); 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. * limitations under the License.
*/ */
import { hostPlatform } from '../../packages/playwright-core/src/utils/hostPlatform';
import { browserTest as it, expect } from '../config/browserTest'; import { browserTest as it, expect } from '../config/browserTest';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; 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('Safari Desktop', async ({ browser, browserName, platform, server, headless }) => {
it.skip(browserName !== 'webkit'); 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' && 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({ const context = await browser.newContext({
deviceScaleFactor: 2 deviceScaleFactor: 2
}); });
@ -52,7 +54,6 @@ it('Safari Desktop', async ({ browser, browserName, platform, server, headless }
actual.video = !!actual.video; actual.video = !!actual.video;
if (platform === 'linux') { if (platform === 'linux') {
expected.subpixelfont = false;
expected.speechrecognition = false; expected.speechrecognition = false;
expected.publickeycredential = false; expected.publickeycredential = false;
expected.mediastream = 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('Mobile Safari', async ({ playwright, browser, browserName, platform, server, headless }) => {
it.skip(browserName !== 'webkit'); 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' && 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 iPhone = playwright.devices['iPhone 12'];
const context = await browser.newContext(iPhone); const context = await browser.newContext(iPhone);
const { actual, expected } = await checkFeatures('mobile-safari-18', context, server); 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') { if (platform === 'linux') {
expected.subpixelfont = false;
expected.speechrecognition = false; expected.speechrecognition = false;
expected.publickeycredential = false; expected.publickeycredential = false;
expected.mediastream = 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.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.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' && isWindows, 'WebPasteboardProxy::allPasteboardItemInfo not implemented for Windows.');
it.fixme(browserName === 'webkit' && isLinux && headless, 'WebPasteboardProxy::allPasteboardItemInfo not implemented for WPE.'); 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); await page.goto(server.EMPTY_PAGE);
// There is no 'clipboard-read' permission in WebKit Web API. // There is no 'clipboard-read' permission in WebKit Web API.
if (browserName !== 'webkit') if (browserName !== 'webkit')
expect(await getPermission(page, 'clipboard-read')).toBe('prompt'); expect(await getPermission(page, 'clipboard-read')).toBe('prompt');
if (!isChromiumHeadedLike) { if (browserName === 'chromium' && channel === 'chromium-headless-shell') {
// Headed Chromium shows a dialog and does not resolve the promise. // Chromium shows a dialog and does not resolve the promise.
const error = await page.evaluate(() => navigator.clipboard.readText()).catch(e => e); const error = await page.evaluate(() => navigator.clipboard.readText()).catch(e => e);
expect(error.toString()).toContain('denied'); expect(error.toString()).toContain('denied');
} }

View file

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

View file

@ -22,7 +22,8 @@ import { verifyViewport } from '../config/utils';
browserTest.describe('page screenshot', () => { browserTest.describe('page screenshot', () => {
browserTest.skip(({ browserName, headless }) => browserName === 'firefox' && !headless, 'Firefox headed produces a different image.'); 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 context = await contextFactory();
const N = 5; const N = 5;
const pages = await Promise.all(Array(N).fill(0).map(async () => { const pages = await Promise.all(Array(N).fill(0).map(async () => {

View file

@ -407,9 +407,9 @@ for (const params of [
height: 768, 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.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 === 'chromium' && platform === 'darwin', 'High DPI maxes image at 600x600');
browserTest.fixme(params.id === 'fit' && browserName === 'webkit' && platform === 'linux', 'Image size is flaky'); browserTest.fixme(params.id === 'fit' && browserName === 'webkit' && platform === 'linux', 'Image size is flaky');
browserTest.fixme(browserName === 'firefox' && !headless, 'Image size is different'); browserTest.fixme(browserName === 'firefox' && !headless, 'Image size is different');

View file

@ -473,9 +473,9 @@ it.describe('screencast', () => {
expect(videoFiles.length).toBe(2); expect(videoFiles.length).toBe(2);
}); });
it('should scale frames down to the requested size ', async ({ browser, browserName, server, headless, trace }, testInfo) => { it('should scale frames down to the requested size ', async ({ browser, browserName, server, headless, channel }, testInfo) => {
const isChromiumHeadlessNew = browserName === 'chromium' && !!headless && !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW; it.fixme(!headless, 'Fails on headed');
it.fixme(!headless || isChromiumHeadlessNew, 'Fails on headed'); it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'Fails on Chromiums');
const context = await browser.newContext({ const context = await browser.newContext({
recordVideo: { recordVideo: {
@ -722,9 +722,9 @@ it.describe('screencast', () => {
expect(files.length).toBe(1); 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.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 === 'firefox' && isWindows, 'https://github.com/microsoft/playwright/issues/14405');
const size = { width: 600, height: 400 }; const size = { width: 600, height: 400 };
const browser = await browserType.launch(); const browser = await browserType.launch();
@ -757,9 +757,9 @@ it.describe('screencast', () => {
expectAll(pixels, almostRed); 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.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 === 'firefox' && isWindows, 'https://github.com/microsoft/playwright/issues/14405');
it.fixme(browserName === 'webkit' && isLinux && !headless, 'https://github.com/microsoft/playwright/issues/22617'); it.fixme(browserName === 'webkit' && isLinux && !headless, 'https://github.com/microsoft/playwright/issues/22617');
const size = { width: 600, height: 400 }; const size = { width: 600, height: 400 };
@ -794,9 +794,10 @@ it.describe('screencast', () => {
expectAll(pixels, almostRed); 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.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 size = { width: 500, height: 400 };
const traceFile = testInfo.outputPath('trace.zip'); 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); 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; let callback;
const result = new Promise(f => callback = f); const result = new Promise(f => callback = f);
page.on('websocket', ws => ws.on('socketerror', callback)); page.on('websocket', ws => ws.on('socketerror', callback));
@ -148,7 +148,7 @@ it('should emit error', async ({ page, server, browserName }) => {
if (browserName === 'firefox') if (browserName === 'firefox')
expect(message).toBe('CLOSE_ABNORMAL'); expect(message).toBe('CLOSE_ABNORMAL');
else else
expect(message).toContain(': 400'); expect(message).toContain(channel?.includes('msedge') ? '' : ': 400');
}); });
it('should not have stray error events', async ({ page, server }) => { 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'), ` await checkAndMatchSnapshot(page.locator('body'), `
- list "my list": - list "my list":
- listitem: "one" - listitem: one
- listitem: "two" - listitem: two
`); `);
}); });
@ -92,7 +92,7 @@ it('should allow text nodes', async ({ page }) => {
await checkAndMatchSnapshot(page.locator('body'), ` await checkAndMatchSnapshot(page.locator('body'), `
- heading "Microsoft" [level=1] - 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'), ` await checkAndMatchSnapshot(page.locator('body'), `
- group: "Summary" - group: Summary
`); `);
}); });
@ -145,10 +145,10 @@ it('should snapshot integration', async ({ page }) => {
await checkAndMatchSnapshot(page.locator('body'), ` await checkAndMatchSnapshot(page.locator('body'), `
- heading "Microsoft" [level=1] - heading "Microsoft" [level=1]
- text: "Open source projects and samples from Microsoft" - text: Open source projects and samples from Microsoft
- list: - list:
- listitem: - listitem:
- group: "Verified" - group: Verified
- listitem: - listitem:
- link "Sponsor" - link "Sponsor"
`); `);
@ -164,7 +164,7 @@ it('should support multiline text', async ({ page }) => {
`); `);
await checkAndMatchSnapshot(page.locator('body'), ` 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(` await expect(page.locator('body')).toMatchAriaSnapshot(`
- paragraph: | - paragraph: |
@ -180,7 +180,7 @@ it('should concatenate span text', async ({ page }) => {
`); `);
await checkAndMatchSnapshot(page.locator('body'), ` 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'), ` 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'), ` 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'), ` await checkAndMatchSnapshot(page.locator('body'), `
- listitem: - listitem:
- text: "a.test.ts" - text: a.test.ts
- button "Run" - button "Run"
- button "Show source" - button "Show source"
- button "Watch" - button "Watch"
- listitem: - listitem:
- text: "snapshot 30ms" - text: snapshot 30ms
- button "Run" - button "Run"
- button "Show source" - button "Show source"
- button "Watch" - button "Watch"
@ -382,12 +382,11 @@ it('should include pseudo codepoints', async ({ page, server }) => {
`); `);
await checkAndMatchSnapshot(page.locator('body'), ` await checkAndMatchSnapshot(page.locator('body'), `
- paragraph: "\ueab2hello" - paragraph: \ueab2hello
`); `);
}); });
it('check aria-hidden text', async ({ page, server }) => { it('check aria-hidden text', async ({ page }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(` await page.setContent(`
<p> <p>
<span>hello</span> <span>hello</span>
@ -396,12 +395,11 @@ it('check aria-hidden text', async ({ page, server }) => {
`); `);
await checkAndMatchSnapshot(page.locator('body'), ` await checkAndMatchSnapshot(page.locator('body'), `
- paragraph: "hello" - paragraph: hello
`); `);
}); });
it('should ignore presentation and none roles', async ({ page, server }) => { it('should ignore presentation and none roles', async ({ page }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(` await page.setContent(`
<ul> <ul>
<li role='presentation'>hello</li> <li role='presentation'>hello</li>
@ -410,6 +408,54 @@ it('should ignore presentation and none roles', async ({ page, server }) => {
`); `);
await checkAndMatchSnapshot(page.locator('body'), ` 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. * limitations under the License.
*/ */
import { stripAnsi } from 'tests/config/utils';
import type { TestServer } from '../config/testserver'; import type { TestServer } from '../config/testserver';
import { test as it, expect } from './pageTest'; 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('waiting for scheduled navigations to finish');
expect(error.message).toContain(`navigated to "${server.PREFIX + '/frames/one-frame.html'}"`); 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', { it('tab should cycle between single input and browser', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' } annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' }
}, async ({ page, browserName, headless }) => { }, async ({ page, browserName, channel }) => {
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW), it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'Chromium keeps input focused.');
'Chromium in headful mode keeps input focused.');
it.fixme(browserName !== 'chromium'); it.fixme(browserName !== 'chromium');
await page.setContent(`<label for="input1">input1</label> await page.setContent(`<label for="input1">input1</label>
<input id="input1"> <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', { it('tab should cycle between document elements and browser', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' } annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' }
}, async ({ page, browserName, headless }) => { }, async ({ page, browserName, channel }) => {
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW), it.fixme(browserName === 'chromium' && channel !== 'chromium-headless-shell', 'Chromium keeps last input focused.');
'Chromium in headful mode keeps last input focused.');
it.fixme(browserName !== 'chromium'); it.fixme(browserName !== 'chromium');
await page.setContent(` await page.setContent(`
<input id="input1"> <input id="input1">

View file

@ -397,7 +397,7 @@ test('expected formatter', async ({ page }) => {
expect(stripAnsi(error.message)).toContain(` expect(stripAnsi(error.message)).toContain(`
Locator: locator('body') Locator: locator('body')
- Expected - 2 - Expected - 2
+ Received string + 3 + Received + 3
- - heading "todos" - - heading "todos"
- - textbox "Wrong text" - - textbox "Wrong text"
@ -405,3 +405,56 @@ Locator: locator('body')
+ - heading "todos" [level=1] + - heading "todos" [level=1]
+ - textbox "What needs to be done?"`); + - 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 showReport();
await page.click('text=fails'); 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 }) => { 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); expect(result.exitCode).toBe(1);
await showReport(); await showReport();
await page.click('text="is a test"'); 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 }) => { 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); expect(result.exitCode).toBe(1);
await showReport(); await showReport();
await page.click('text="is a test"'); await page.click('text="is a test"');
const stricken = await page.locator('css=strike').innerText(); await expect(page.locator('.test-error-view').getByText('old')).toHaveCSS('text-decoration', 'line-through solid rgb(205, 49, 49)');
expect(stricken).toBe('old'); 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 }) => { 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); expect(result.exitCode).toBe(1);
await showReport(); await showReport();
await page.locator('text=sample').first().click(); await page.getByText('sample').first().click();
await expect(page.locator('text=ouch')).toHaveCount(1); await expect(page.getByText('ouch')).toHaveCount(2);
await page.locator('text=All').first().click(); await page.getByText('All').first().click();
await page.locator('text=sample').nth(1).click(); await page.getByText('sample').nth(1).click();
await expect(page.locator('text=Before Hooks')).toBeVisible(); await expect(page.getByText('Before Hooks')).toBeVisible();
await expect(page.locator('text=ouch')).toBeHidden(); await expect(page.getByText('ouch')).toBeHidden();
}); });
test('should group similar / loop steps', async ({ runInlineTest, showReport, page }) => { test('should group similar / loop steps', async ({ runInlineTest, showReport, page }) => {

View file

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

View file

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"dependencies": { "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', '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'); const label = page.getByText('React DevTools');
await expect(label).toHaveCSS('color', 'rgb(255, 0, 0)'); await expect(label).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(label).toHaveCSS('font-weight', '700'); await expect(label).toHaveCSS('font-weight', '700');

View file

@ -15,7 +15,8 @@
*/ */
import * as fs from 'fs'; 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' }); 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) => { 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 trap "cd $(pwd -P)" EXIT
SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)" 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")" cd "$(dirname "$0")"
PACKAGE_VERSION=$(node -p "require('../../package.json').version") 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 && \ apt-get install -y curl wget gpg ca-certificates && \
mkdir -p /etc/apt/keyrings && \ mkdir -p /etc/apt/keyrings && \
curl -sL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ 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 update && \
apt-get install -y nodejs && \ apt-get install -y nodejs && \
# Feature-parity with node.js base images. # 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 && \ apt-get install -y curl wget gpg ca-certificates && \
mkdir -p /etc/apt/keyrings && \ mkdir -p /etc/apt/keyrings && \
curl -sL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ 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 update && \
apt-get install -y nodejs && \ apt-get install -y nodejs && \
# Feature-parity with node.js base images. # Feature-parity with node.js base images.