From 25dd9b5cd49470215c71d77ef46f81a66960ec3d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 10 Oct 2024 01:37:46 -0700 Subject: [PATCH 01/29] feat: config.build.tsconfig (#33026) Allows to specify `tsconfig` in the configuration file, which applies to test files but not the config file itself. Fixes #32808. --- docs/src/test-api/class-testconfig.md | 16 +++++ docs/src/test-typescript-js.md | 10 ++++ packages/playwright/src/common/config.ts | 2 + .../playwright/src/common/configLoader.ts | 2 + .../playwright/src/common/esmLoaderHost.ts | 1 + packages/playwright/types/test.d.ts | 19 ++++++ tests/playwright-test/resolver.spec.ts | 59 +++++++++++++++++++ 7 files changed, 109 insertions(+) diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index d013f5e4ea..0d1f4c1538 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -552,6 +552,22 @@ export default defineConfig({ }); ``` +## property: TestConfig.tsconfig +* since: v1.49 +- type: ?<[string]> + +Path to a single `tsconfig` applicable to all imported files. By default, `tsconfig` for each imported file is looked up separately. Note that `tsconfig` property has no effect while the configuration file or any of its dependencies are loaded. Ignored when `--tsconfig` command line option is specified. + +**Usage** + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + tsconfig: './tsconfig.test.json', +}); +``` + ## property: TestConfig.updateSnapshots * since: v1.10 - type: ?<[UpdateSnapshots]<"all"|"none"|"missing">> diff --git a/docs/src/test-typescript-js.md b/docs/src/test-typescript-js.md index 6e18b3c615..538f5a137e 100644 --- a/docs/src/test-typescript-js.md +++ b/docs/src/test-typescript-js.md @@ -90,6 +90,16 @@ Alternatively, you can specify a single tsconfig file to use in the command line npx playwright test --tsconfig=tsconfig.test.json ``` +You can specify a single tsconfig file in the config file, that will be used for loading test files, reporters, etc. However, it will not be used while loading the playwright config itself or any files imported from it. + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + tsconfig: './tsconfig.test.json', +}); +``` + ## Manually compile tests with TypeScript Sometimes, Playwright Test will not be able to transform your TypeScript code correctly, for example when you are using experimental or very recent features of TypeScript, usually configured in `tsconfig.json`. diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index d7fb499645..a694839f81 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -45,6 +45,7 @@ export class FullConfigInternal { readonly webServers: NonNullable[]; readonly plugins: TestRunnerPluginRegistration[]; readonly projects: FullProjectInternal[] = []; + readonly singleTSConfigPath?: string; cliArgs: string[] = []; cliGrep: string | undefined; cliGrepInvert: string | undefined; @@ -69,6 +70,7 @@ export class FullConfigInternal { this.configCLIOverrides = configCLIOverrides; const privateConfiguration = (userConfig as any)['@playwright/test']; this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p })); + this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig); this.config = { configFile: resolvedConfigFile, diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 5ed1c68ea7..eef56c4458 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -118,6 +118,8 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || []; const external = userConfig.build?.external || []; setTransformConfig({ babelPlugins, external }); + if (!overrides?.tsconfig) + setSingleTSConfig(fullConfig?.singleTSConfigPath); // 4. Send transform options to ESM loader. await configureESMLoaderTransformConfig(); diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts index 1611b0f91d..9b5b4f9b8c 100644 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ b/packages/playwright/src/common/esmLoaderHost.ts @@ -77,5 +77,6 @@ export async function configureESMLoader() { export async function configureESMLoaderTransformConfig() { if (!loaderChannel) return; + await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() }); await loaderChannel.send('setTransformConfig', { config: transformConfig() }); } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 91fe2f2b5c..a1128c519e 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1642,6 +1642,25 @@ interface TestConfig { */ timeout?: number; + /** + * Path to a single `tsconfig` applicable to all imported files. By default, `tsconfig` for each imported file is + * looked up separately. Note that `tsconfig` property has no effect while the configuration file or any of its + * dependencies are loaded. Ignored when `--tsconfig` command line option is specified. + * + * **Usage** + * + * ```js + * // playwright.config.ts + * import { defineConfig } from '@playwright/test'; + * + * export default defineConfig({ + * tsconfig: './tsconfig.test.json', + * }); + * ``` + * + */ + tsconfig?: string; + /** * Whether to update expected snapshots with the actual results produced by the test run. Defaults to `'missing'`. * - `'all'` - All tests that are executed will update snapshots that did not match. Matching snapshots will not be diff --git a/tests/playwright-test/resolver.spec.ts b/tests/playwright-test/resolver.spec.ts index 037ba14f22..d4dec96495 100644 --- a/tests/playwright-test/resolver.spec.ts +++ b/tests/playwright-test/resolver.spec.ts @@ -675,6 +675,65 @@ test('should respect --tsconfig option', async ({ runInlineTest }) => { expect(result.output).not.toContain(`Could not`); }); +test('should respect config.tsconfig option', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export { configFoo } from '~/foo'; + export default { + testDir: './tests', + tsconfig: './tsconfig.tests.json', + }; + `, + 'tsconfig.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./mapped-from-config/*"], + }, + }, + }`, + 'mapped-from-config/foo.ts': ` + export const configFoo = 17; + `, + 'tsconfig.tests.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./mapped-from-tests/*"], + }, + }, + }`, + 'mapped-from-tests/foo.ts': ` + export const testFoo = 42; + `, + 'tests/tsconfig.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["../should-be-ignored/*"], + }, + }, + }`, + 'tests/a.test.ts': ` + import { testFoo } from '~/foo'; + import { configFoo } from '../playwright.config'; + import { test, expect } from '@playwright/test'; + test('test', ({}) => { + expect(testFoo).toBe(42); + expect(configFoo).toBe(17); + }); + `, + 'should-be-ignored/foo.ts': ` + export const testFoo = 43; + export const configFoo = 18; + `, + }); + + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + expect(result.output).not.toContain(`Could not`); +}); + test.describe('directory imports', () => { test('should resolve index.js without path mapping in CJS', async ({ runInlineTest, runTSC }) => { const files = { From 217b57df4c15913b345beb68cf1818d9387fdbba Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 10 Oct 2024 04:10:54 -0700 Subject: [PATCH 02/29] feat(webkit): roll to r2089 (#33039) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- packages/playwright-core/src/server/webkit/protocol.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index c177343ebf..bc52be8381 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2084", + "revision": "2089", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index 3ddfda4627..7c279f9e1e 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -6510,7 +6510,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the /** * List of settings able to be overridden by WebInspector. */ - export type Setting = "PrivateClickMeasurementDebugModeEnabled"|"AuthorAndUserStylesEnabled"|"ICECandidateFilteringEnabled"|"ITPDebugModeEnabled"|"ImagesEnabled"|"MediaCaptureRequiresSecureConnection"|"MockCaptureDevicesEnabled"|"NeedsSiteSpecificQuirks"|"ScriptEnabled"|"ShowDebugBorders"|"ShowRepaintCounter"|"WebSecurityEnabled"|"DeviceOrientationEventEnabled"|"SpeechRecognitionEnabled"|"PointerLockEnabled"|"NotificationsEnabled"|"FullScreenEnabled"|"InputTypeMonthEnabled"|"InputTypeWeekEnabled"; + export type Setting = "PrivateClickMeasurementDebugModeEnabled"|"AuthorAndUserStylesEnabled"|"ICECandidateFilteringEnabled"|"ITPDebugModeEnabled"|"ImagesEnabled"|"MediaCaptureRequiresSecureConnection"|"MockCaptureDevicesEnabled"|"NeedsSiteSpecificQuirks"|"ScriptEnabled"|"ShowDebugBorders"|"ShowRepaintCounter"|"WebSecurityEnabled"|"DeviceOrientationEventEnabled"|"SpeechRecognitionEnabled"|"PointerLockEnabled"|"NotificationsEnabled"|"FullScreenEnabled"|"InputTypeMonthEnabled"|"InputTypeWeekEnabled"|"FixedBackgroundsPaintRelativeToDocument"; /** * A user preference that can be overriden by Web Inspector, like an accessibility preference. */ From 7de084b6dc2ec090fef800099cfa42db42895b04 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 10 Oct 2024 07:32:00 -0700 Subject: [PATCH 03/29] feat(chromium-tip-of-tree): roll to r1268 (#33042) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index bc52be8381..1fbe52c90d 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1267", + "revision": "1268", "installByDefault": false, - "browserVersion": "131.0.6764.0" + "browserVersion": "131.0.6768.0" }, { "name": "firefox", From 82fe882004980e3709ebbcd52a46f99fa0691306 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 10 Oct 2024 14:32:27 -0700 Subject: [PATCH 04/29] fix(webkit): scroll mobile page with background-attachment: fixed (#33048) Fixes #31551 Fixes #23573 --- packages/playwright-core/src/server/webkit/wkPage.ts | 1 + tests/library/browsercontext-viewport-mobile.spec.ts | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 3ba773241a..0bd82ed338 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -231,6 +231,7 @@ export class WKPage implements PageDelegate { promises.push(session.send('Page.overrideSetting', { setting: 'PointerLockEnabled', value: !contextOptions.isMobile })); promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeMonthEnabled', value: contextOptions.isMobile })); promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeWeekEnabled', value: contextOptions.isMobile })); + promises.push(session.send('Page.overrideSetting', { setting: 'FixedBackgroundsPaintRelativeToDocument', value: contextOptions.isMobile })); await Promise.all(promises); } diff --git a/tests/library/browsercontext-viewport-mobile.spec.ts b/tests/library/browsercontext-viewport-mobile.spec.ts index ed774e383a..58c85d1a07 100644 --- a/tests/library/browsercontext-viewport-mobile.spec.ts +++ b/tests/library/browsercontext-viewport-mobile.spec.ts @@ -54,7 +54,7 @@ it.describe('mobile viewport', () => { } }); - it('should be detectable', async ({ playwright, browser, server, browserName, platform }) => { + it('should be detectable', async ({ playwright, browser }) => { const iPhone = playwright.devices['iPhone 6']; const context = await browser.newContext({ ...iPhone }); const page = await context.newPage(); @@ -62,7 +62,7 @@ it.describe('mobile viewport', () => { await context.close(); }); - it('should detect touch when applying viewport with touches', async ({ browser, server, browserName, platform }) => { + it('should detect touch when applying viewport with touches', async ({ browser, server }) => { const context = await browser.newContext({ viewport: { width: 800, height: 600 }, hasTouch: true }); const page = await context.newPage(); await page.goto(server.EMPTY_PAGE); @@ -154,7 +154,7 @@ it.describe('mobile viewport', () => { await desktopPage.close(); }); - it('mouse should work with mobile viewports and cross process navigations', async ({ browser, server, browserName }) => { + it('mouse should work with mobile viewports and cross process navigations', async ({ browser, server }) => { // @see https://crbug.com/929806 const context = await browser.newContext({ viewport: { width: 360, height: 640 }, isMobile: true }); const page = await context.newPage(); @@ -193,8 +193,7 @@ it.describe('mobile viewport', () => { { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31551' }, { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/23573' }, ] - }, async ({ playwright, browser, server, browserName, isLinux, headless }) => { - it.fixme(browserName === 'webkit' && isLinux && headless, 'Fails on WPE apparently due to accelerated compositing + fixed layout'); + }, async ({ playwright, browser, server }) => { const iPhone = playwright.devices['iPhone 12']; const context = await browser.newContext({ ...iPhone }); const page = await context.newPage(); @@ -204,7 +203,7 @@ it.describe('mobile viewport', () => { await context.close(); }); - it('view scale should reset after navigation', async ({ browser, browserName }) => { + it('view scale should reset after navigation', async ({ browser }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/26876' }); const context = await browser.newContext({ viewport: { width: 390, height: 664 }, From 10a9e1c730703a8f857b85d3338205d79805a5a8 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:08:03 -0700 Subject: [PATCH 05/29] feat(webkit): roll to r2090 (#33050) --- packages/playwright-core/browsers.json | 2 +- tests/page/page-goto.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 1fbe52c90d..8cfd41cd8c 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2089", + "revision": "2090", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/tests/page/page-goto.spec.ts b/tests/page/page-goto.spec.ts index ccab5abeba..e3ffb1f93c 100644 --- a/tests/page/page-goto.spec.ts +++ b/tests/page/page-goto.spec.ts @@ -258,7 +258,8 @@ it('should work with subframes return 204 with domcontentloaded', async ({ page, await page.goto(server.PREFIX + '/frames/one-frame.html', { waitUntil: 'domcontentloaded' }); }); -it('should fail when server returns 204', async ({ page, server, browserName }) => { +it('should fail when server returns 204', async ({ page, server, browserName, isLinux }) => { + it.fixme(browserName === 'webkit' && isLinux, 'Regressed in https://github.com/microsoft/playwright-browsers/pull/1297'); // WebKit just loads an empty page. server.setRoute('/empty.html', (req, res) => { res.statusCode = 204; From b9cce598dd9cc3af1246e7c8317ade5e3a43deb0 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 10 Oct 2024 16:49:17 -0700 Subject: [PATCH 06/29] fix(screenshot): show image diff inline in errors list (#32997) The diff is now shown inline in the errors list. There are 2 possible failures of toHaveScreenshot * Previous and actual snapshot mismatch. In this case html report will show diff between Actual/Previous and have Expected as a separate screenshot. * Actual/Previous are equal but they differ from the expected. In this case html report only contains Actual/Expected images and the diff. Reference: https://github.com/microsoft/playwright/issues/32341 image --- .gitignore | 1 + packages/html-reporter/src/testErrorView.css | 7 +- packages/html-reporter/src/testErrorView.tsx | 36 +++++++--- packages/html-reporter/src/testResultView.tsx | 40 +++++++++-- packages/playwright-core/src/server/page.ts | 9 ++- .../src/matchers/toMatchSnapshot.ts | 4 +- packages/web/src/shared/imageDiffView.tsx | 46 ++++++------ tests/playwright-test/reporter-html.spec.ts | 72 +++++++++++-------- 8 files changed, 142 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index aadc481067..1f3b9a7a72 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ test-results .cache/ .eslintcache playwright.env +firefox diff --git a/packages/html-reporter/src/testErrorView.css b/packages/html-reporter/src/testErrorView.css index afb543a0c2..d5a4534e7e 100644 --- a/packages/html-reporter/src/testErrorView.css +++ b/packages/html-reporter/src/testErrorView.css @@ -14,9 +14,8 @@ limitations under the License. */ -.test-error-message { +.test-error-view { white-space: pre; - font-family: monospace; overflow: auto; flex: none; padding: 0; @@ -26,3 +25,7 @@ line-height: initial; margin-bottom: 6px; } + +.test-error-text { + font-family: monospace; +} diff --git a/packages/html-reporter/src/testErrorView.tsx b/packages/html-reporter/src/testErrorView.tsx index 5208158b1c..520da1fc19 100644 --- a/packages/html-reporter/src/testErrorView.tsx +++ b/packages/html-reporter/src/testErrorView.tsx @@ -17,21 +17,39 @@ import ansi2html from 'ansi-to-html'; import * as React from 'react'; import './testErrorView.css'; +import type { ImageDiff } from '@web/shared/imageDiffView'; +import { ImageDiffView } from '@web/shared/imageDiffView'; export const TestErrorView: React.FC<{ error: string; }> = ({ error }) => { - const html = React.useMemo(() => { - const config: any = { - bg: 'var(--color-canvas-subtle)', - fg: 'var(--color-fg-default)', - }; - config.colors = ansiColors; - return new ansi2html(config).toHtml(escapeHTML(error)); - }, [error]); - return
; + const html = React.useMemo(() => ansiErrorToHtml(error), [error]); + return
; }; +export const TestScreenshotErrorView: React.FC<{ + errorPrefix?: string, + diff: ImageDiff, + errorSuffix?: string, +}> = ({ errorPrefix, diff, errorSuffix }) => { + const prefixHtml = React.useMemo(() => ansiErrorToHtml(errorPrefix), [errorPrefix]); + const suffixHtml = React.useMemo(() => ansiErrorToHtml(errorSuffix), [errorSuffix]); + return
+
+ +
+
; +}; + +function ansiErrorToHtml(text?: string): string { + const config: any = { + bg: 'var(--color-canvas-subtle)', + fg: 'var(--color-fg-default)', + }; + config.colors = ansiColors; + return new ansi2html(config).toHtml(escapeHTML(text || '')); +} + const ansiColors = { 0: '#000', 1: '#C00', diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 8ee36d0cda..273703a0c7 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -24,7 +24,7 @@ import { AttachmentLink, generateTraceUrl } from './links'; import { statusIcon } from './statusIcon'; import type { ImageDiff } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView'; -import { TestErrorView } from './testErrorView'; +import { TestErrorView, TestScreenshotErrorView } from './testErrorView'; import './testResultView.css'; function groupImageDiffs(screenshots: Set): ImageDiff[] { @@ -67,7 +67,7 @@ export const TestResultView: React.FC<{ anchor: 'video' | 'diff' | '', }> = ({ result, anchor }) => { - const { screenshots, videos, traces, otherAttachments, diffs, htmls } = React.useMemo(() => { + const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => { const attachments = result?.attachments || []; const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/'))); const videos = attachments.filter(a => a.name === 'video'); @@ -76,7 +76,8 @@ export const TestResultView: React.FC<{ const otherAttachments = new Set(attachments); [...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a)); const diffs = groupImageDiffs(screenshots); - return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, htmls }; + const errors = classifyErrors(result.errors, diffs); + return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls }; }, [result]); const videoRef = React.useRef(null); @@ -94,15 +95,19 @@ export const TestResultView: React.FC<{ }, [scrolled, anchor, setScrolled, videoRef]); return
- {!!result.errors.length && - {result.errors.map((error, index) => )} + {!!errors.length && + {errors.map((error, index) => { + if (error.type === 'screenshot') + return ; + return ; + })} } {!!result.steps.length && {result.steps.map((step, i) => )} } {diffs.map((diff, index) => - + )} @@ -145,6 +150,29 @@ export const TestResultView: React.FC<{
; }; +function classifyErrors(testErrors: string[], diffs: ImageDiff[]) { + return testErrors.map(error => { + if (error.includes('Screenshot comparison failed:')) { + const matchingDiff = diffs.find(diff => { + const attachmentName = diff.actual?.attachment.name; + return attachmentName && error.includes(attachmentName); + }); + + if (matchingDiff) { + const lines = error.split('\n'); + const index = lines.findIndex(line => /Expected:|Previous:|Received:/.test(line)); + const errorPrefix = index !== -1 ? lines.slice(0, index).join('\n') : lines[0]; + + const diffIndex = lines.findIndex(line => / +Diff:/.test(line)); + const errorSuffix = diffIndex !== -1 ? lines.slice(diffIndex + 2).join('\n') : lines.slice(1).join('\n'); + + return { type: 'screenshot', diff: matchingDiff, errorPrefix, errorSuffix }; + } + } + return { type: 'regular', error }; + }); +} + const StepTreeItem: React.FC<{ step: TestStep; depth: number, diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index f7f1ef67e8..b78bd91ee1 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -43,7 +43,7 @@ import type { TimeoutOptions } from '../common/types'; import { isInvalidSelectorError } from '../utils/isomorphic/selectorParser'; import { parseEvaluationResultValue, source } from './isomorphic/utilityScriptSerializers'; import type { SerializedValue } from './isomorphic/utilityScriptSerializers'; -import { TargetClosedError } from './errors'; +import { TargetClosedError, TimeoutError } from './errors'; import { asLocator } from '../utils'; import { helper } from './helper'; @@ -662,7 +662,7 @@ export class Page extends SdkObject { return {}; } - if (areEqualScreenshots(actual, options.expected, previous)) { + if (areEqualScreenshots(actual, options.expected, undefined)) { progress.log(`screenshot matched expectation`); return {}; } @@ -672,10 +672,13 @@ export class Page extends SdkObject { // A: We want user to receive a friendly diff between actual and expected/previous. if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) throw e; + let errorMessage = e.message; + if (e instanceof TimeoutError && intermediateResult?.previous) + errorMessage = `Failed to take two consecutive stable screenshots. ${e.message}`; return { log: e.message ? [...metadata.log, e.message] : metadata.log, ...intermediateResult, - errorMessage: e.message, + errorMessage, }; }); } diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 0923a10e82..b3fa3f556e 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -423,7 +423,7 @@ export async function toHaveScreenshot( // - regular matcher (i.e. not a `.not`) // - perhaps an 'all' flag to update non-matching screenshots expectScreenshotOptions.expected = await fs.promises.readFile(helper.expectedPath); - const { actual, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions); + const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions); if (!errorMessage) return helper.handleMatching(); @@ -436,7 +436,7 @@ export async function toHaveScreenshot( return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true); } - return helper.handleDifferent(actual, expectScreenshotOptions.expected, undefined, diff, errorMessage, log); + return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, errorMessage, log); } function writeFileSync(aPath: string, content: Buffer | string) { diff --git a/packages/web/src/shared/imageDiffView.tsx b/packages/web/src/shared/imageDiffView.tsx index ea0f1e0042..6f0028bca9 100644 --- a/packages/web/src/shared/imageDiffView.tsx +++ b/packages/web/src/shared/imageDiffView.tsx @@ -61,11 +61,13 @@ const checkerboardStyle: React.CSSProperties = { export const ImageDiffView: React.FC<{ diff: ImageDiff, noTargetBlank?: boolean, -}> = ({ diff, noTargetBlank }) => { + hideDetails?: boolean, +}> = ({ diff, noTargetBlank, hideDetails }) => { const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual'); const [showSxsDiff, setShowSxsDiff] = React.useState(false); const [expectedImage, setExpectedImage] = React.useState(null); + const [expectedImageTitle, setExpectedImageTitle] = React.useState('Expected'); const [actualImage, setActualImage] = React.useState(null); const [diffImage, setDiffImage] = React.useState(null); const [measure, ref] = useMeasure(); @@ -73,6 +75,7 @@ export const ImageDiffView: React.FC<{ React.useEffect(() => { (async () => { setExpectedImage(await loadImage(diff.expected?.attachment.path)); + setExpectedImageTitle(diff.expected?.title || 'Expected'); setActualImage(await loadImage(diff.actual?.attachment.path)); setDiffImage(await loadImage(diff.diff?.attachment.path)); })(); @@ -98,31 +101,31 @@ export const ImageDiffView: React.FC<{
{diff.diff &&
setMode('diff')}>Diff
}
setMode('actual')}>Actual
-
setMode('expected')}>Expected
+
setMode('expected')}>{expectedImageTitle}
setMode('sxs')}>Side by side
setMode('slider')}>Slider
- {diff.diff && mode === 'diff' && } - {diff.diff && mode === 'actual' && } - {diff.diff && mode === 'expected' && } - {diff.diff && mode === 'slider' && } + {diff.diff && mode === 'diff' && } + {diff.diff && mode === 'actual' && } + {diff.diff && mode === 'expected' && } + {diff.diff && mode === 'slider' && } {diff.diff && mode === 'sxs' &&
- - setShowSxsDiff(!showSxsDiff)} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} /> + + setShowSxsDiff(!showSxsDiff)} hideSize={hideDetails} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
} - {!diff.diff && mode === 'actual' && } - {!diff.diff && mode === 'expected' && } + {!diff.diff && mode === 'actual' && } + {!diff.diff && mode === 'expected' && } {!diff.diff && mode === 'sxs' &&
- +
}
- } } ; }; @@ -133,7 +136,9 @@ export const ImageDiffSlider: React.FC<{ canvasWidth: number, canvasHeight: number, scale: number, -}> = ({ expectedImage, actualImage, canvasWidth, canvasHeight, scale }) => { + expectedTitle: string, + hideSize?: boolean, +}> = ({ expectedImage, actualImage, canvasWidth, canvasHeight, scale, expectedTitle, hideSize }) => { const absoluteStyle: React.CSSProperties = { position: 'absolute', top: 0, @@ -144,7 +149,7 @@ export const ImageDiffSlider: React.FC<{ const sameSize = expectedImage.naturalWidth === actualImage.naturalWidth && expectedImage.naturalHeight === actualImage.naturalHeight; return
-
+ {!hideSize &&
{!sameSize && Expected } {expectedImage.naturalWidth} x @@ -153,7 +158,7 @@ export const ImageDiffSlider: React.FC<{ {!sameSize && {actualImage.naturalWidth}} {!sameSize && x} {!sameSize && {actualImage.naturalHeight}} -
+
}
setSlider(offsets[0])} resizerColor={'#57606a80'} resizerWidth={6}> - Expected @@ -179,18 +184,19 @@ const ImageWithSize: React.FunctionComponent<{ image: HTMLImageElement, title?: string, alt?: string, + hideSize?: boolean, canvasWidth: number, canvasHeight: number, scale: number, onClick?: () => void; -}> = ({ image, title, alt, canvasWidth, canvasHeight, scale, onClick }) => { +}> = ({ image, title, alt, hideSize, canvasWidth, canvasHeight, scale, onClick }) => { return
-
+ {!hideSize &&
{title && {title}} {image.naturalWidth} x {image.naturalHeight} -
+
}
{ - await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Diff'); - }); + for (const testId of ['test-results-image-diff', 'test-screenshot-error-view']) { + await test.step(testId, async () => { + const imageDiff = page.getByTestId(testId).getByTestId('test-result-image-mismatch'); + await test.step('Diff', async () => { + await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Diff'); + }); - await test.step('Actual', async () => { - await imageDiff.getByText('Actual', { exact: true }).click(); - await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Actual'); - }); + await test.step('Actual', async () => { + await imageDiff.getByText('Actual', { exact: true }).click(); + await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Actual'); + }); - await test.step('Expected', async () => { - await imageDiff.getByText('Expected', { exact: true }).click(); - await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Expected'); - }); + await test.step('Expected', async () => { + await imageDiff.getByText('Expected', { exact: true }).click(); + await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Expected'); + }); - await test.step('Side by side', async () => { - await imageDiff.getByText('Side by side').click(); - await expect(imageDiff.locator('img')).toHaveCount(2); - await expect(imageDiff.locator('img').first()).toHaveAttribute('alt', 'Expected'); - await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual'); - await imageDiff.locator('img').last().click(); - await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Diff'); - }); + await test.step('Side by side', async () => { + await imageDiff.getByText('Side by side').click(); + await expect(imageDiff.locator('img')).toHaveCount(2); + await expect(imageDiff.locator('img').first()).toHaveAttribute('alt', 'Expected'); + await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual'); + await imageDiff.locator('img').last().click(); + await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Diff'); + }); - await test.step('Slider', async () => { - await imageDiff.getByText('Slider', { exact: true }).click(); - await expect(imageDiff.locator('img')).toHaveCount(2); - await expect(imageDiff.locator('img').first()).toHaveAttribute('alt', 'Expected'); - await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual'); - }); + await test.step('Slider', async () => { + await imageDiff.getByText('Slider', { exact: true }).click(); + await expect(imageDiff.locator('img')).toHaveCount(2); + await expect(imageDiff.locator('img').first()).toHaveAttribute('alt', 'Expected'); + await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual'); + }); + }); + } }); test('should include multiple image diffs', async ({ runInlineTest, page, showReport }) => { @@ -285,8 +289,14 @@ for (const useIntermediateMergeReport of [false] as const) { await showReport(); await page.click('text=fails'); - await expect(page.locator('data-testid=test-result-image-mismatch')).toHaveCount(3); - await expect(page.locator('text=Image mismatch:')).toHaveText([ + await expect(page.getByTestId('test-screenshot-error-view').getByTestId('error-suffix')).toContainText([ + `> 6 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`, + `> 7 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`, + `> 8 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`, + ]); + const imageDiffs = page.getByTestId('test-results-image-diff'); + await expect(imageDiffs.getByTestId('test-result-image-mismatch')).toHaveCount(3); + await expect(imageDiffs.getByText('Image mismatch:')).toHaveText([ 'Image mismatch: expected.png', 'Image mismatch: expected-1.png', 'Image mismatch: expected-2.png', @@ -323,7 +333,7 @@ for (const useIntermediateMergeReport of [false] as const) { await expect(page.getByTestId('test-result-image-mismatch-tabs').locator('div')).toHaveText([ 'Diff', 'Actual', - 'Expected', + 'Previous', 'Side by side', 'Slider', ]); @@ -460,7 +470,7 @@ for (const useIntermediateMergeReport of [false] as const) { await showReport(); await page.click('text=fails'); - await expect(page.locator('.test-error-message span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)'); + await expect(page.locator('.test-error-view span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)'); }); test('should show trace source', async ({ runInlineTest, page, showReport }) => { From 87624c543470fe95f09ac29a6244aa12d3a02dd5 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 11 Oct 2024 16:33:17 +0200 Subject: [PATCH 07/29] Revert "fix(codegen): fix unselect issue (#32127)" (#33065) This reverts commit 5121b19ac618432c9203282c07d8448b4889869d. The tree is red and fixing seems non-trivial. Closes https://github.com/microsoft/playwright/issues/33064 Closes https://github.com/microsoft/playwright/pull/33060 --- .../src/server/codegen/java.ts | 2 +- .../src/server/codegen/javascript.ts | 2 +- tests/library/inspector/cli-codegen-1.spec.ts | 89 +------------------ 3 files changed, 3 insertions(+), 90 deletions(-) diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 5b417c6c3a..507a040bce 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -122,7 +122,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { case 'navigate': return `${subject}.navigate(${quote(action.url)});`; case 'select': - return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.selectOption(${formatSelectOption(action.options.length === 1 ? action.options[0] : action.options)});`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.selectOption(${formatSelectOption(action.options.length > 1 ? action.options : action.options[0])});`; case 'assertText': return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${action.substring ? 'containsText' : 'hasText'}(${quote(action.text)});`; case 'assertChecked': diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index b68a8104a8..558670cd47 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -106,7 +106,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { case 'navigate': return `await ${subject}.goto(${quote(action.url)});`; case 'select': - return `await ${subject}.${this._asLocator(action.selector)}.selectOption(${formatObject(action.options.length === 1 ? action.options[0] : action.options)});`; + return `await ${subject}.${this._asLocator(action.selector)}.selectOption(${formatObject(action.options.length > 1 ? action.options : action.options[0])});`; case 'assertText': return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'toContainText' : 'toHaveText'}(${quote(action.text)});`; case 'assertChecked': diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index 765ad241b6..a876a25e47 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -15,7 +15,7 @@ */ import { test, expect } from './inspectorTest'; -import type { ConsoleMessage, Locator } from 'playwright'; +import type { ConsoleMessage } from 'playwright'; test.describe('cli codegen', () => { test.skip(({ mode }) => mode !== 'default'); @@ -682,93 +682,6 @@ await page.Locator(\"#age\").SelectOptionAsync(new[] { \"2\" });`); expect(message.text()).toBe('2'); }); - const clickMultipleSelectOption = async (locator: Locator, withCtrlOrMeta = false) => { - const page = locator.page(); - - // Webkit can't click multiple select options - // https://github.com/microsoft/playwright/issues/32126 - if (page.context().browser().browserType().name() === 'webkit') { - const elem = await locator.elementHandle(); - const rect = await elem!.evaluate(e => { - return e.getBoundingClientRect()!; - }); - if (withCtrlOrMeta) - await page.keyboard.down('ControlOrMeta'); - - await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height / 2); - if (withCtrlOrMeta) - await page.keyboard.up('ControlOrMeta'); - - } else { - await locator.click({ modifiers: withCtrlOrMeta ? ['ControlOrMeta'] : [] }); - } - }; - - test('should select with multiple attribute', async ({ openRecorder }) => { - const { page, recorder } = await openRecorder(); - - await recorder.setContentAndWait(``); - - const locator = await recorder.hoverOverElement('select'); - expect(locator).toBe(`locator('#age')`); - await clickMultipleSelectOption(page.getByRole('option', { name: '1' })); - - const [message, sources] = await Promise.all([ - page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text().includes('2')), - recorder.waitForOutput('JavaScript', 'selectOption(['), - clickMultipleSelectOption(page.getByRole('option', { name: '2' }), true) - ]); - - expect(sources.get('JavaScript')!.text).toContain(` - await page.locator('#age').selectOption(['1', '2']);`); - - expect(sources.get('Java')!.text).toContain(` - page.locator("#age").selectOption(new String[] {"1", "2"});`); - - expect(sources.get('Python')!.text).toContain(` - page.locator("#age").select_option(["1", "2"])`); - - expect(sources.get('Python Async')!.text).toContain(` - await page.locator("#age").select_option(["1", "2"])`); - - expect(sources.get('C#')!.text).toContain(` -await page.Locator("#age").SelectOptionAsync(new[] { "1", "2" });`); - - expect(message.text()).toBe('[1,2]'); - }); - - test('should unselect with multiple attribute', async ({ openRecorder }) => { - const { page, recorder } = await openRecorder(); - - await recorder.setContentAndWait(``); - const locator = await recorder.hoverOverElement('select'); - expect(locator).toBe(`locator('#age')`); - await clickMultipleSelectOption(page.getByRole('option', { name: '1' })); - - const [message, sources] = await Promise.all([ - page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === '[]'), - recorder.waitForOutput('JavaScript', 'selectOption(['), - clickMultipleSelectOption(page.getByRole('option', { name: '1' }), true) - ]); - - expect(sources.get('JavaScript')!.text).toContain(` - await page.locator('#age').selectOption([]);`); - - expect(sources.get('Java')!.text).toContain(` - page.locator("#age").selectOption(new String[0]);`); - - expect(sources.get('Python')!.text).toContain(` - page.locator("#age").select_option([])`); - - expect(sources.get('Python Async')!.text).toContain(` - await page.locator("#age").select_option([])`); - - expect(sources.get('C#')!.text).toContain(` -await page.Locator("#age").SelectOptionAsync(new[] { });`); - - expect(message.text()).toBe('[]'); - }); - test('should await popup', async ({ openRecorder }) => { const { page, recorder } = await openRecorder(); await recorder.setContentAndWait('link'); From e4b0d5e6dd05d774c4d7dea239d7dc20571c083b Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Fri, 11 Oct 2024 07:33:59 -0700 Subject: [PATCH 08/29] feat(chromium): roll to r1142 (#33062) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 4 +- .../src/server/deviceDescriptorsSource.json | 96 +++++++++---------- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 6df15c2024..e865883de9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-130.0.6723.31-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-131.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![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) [![Chromium version](https://img.shields.io/badge/chromium-130.0.6723.44-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-131.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 130.0.6723.31 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 130.0.6723.44 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 131.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 8cfd41cd8c..7921a26035 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,9 +3,9 @@ "browsers": [ { "name": "chromium", - "revision": "1141", + "revision": "1142", "installByDefault": true, - "browserVersion": "130.0.6723.31" + "browserVersion": "130.0.6723.44" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 1b0a015346..6609089642 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -1098,7 +1098,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1109,7 +1109,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1120,7 +1120,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1131,7 +1131,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1142,7 +1142,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1153,7 +1153,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1164,7 +1164,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1175,7 +1175,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1186,7 +1186,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1197,7 +1197,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1219,7 +1219,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1230,7 +1230,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1241,7 +1241,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1252,7 +1252,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1263,7 +1263,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1274,7 +1274,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1285,7 +1285,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1296,7 +1296,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1307,7 +1307,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1362,7 +1362,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1373,7 +1373,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1384,7 +1384,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1395,7 +1395,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1406,7 +1406,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1417,7 +1417,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 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/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1428,7 +1428,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1439,7 +1439,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1450,7 +1450,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1465,7 +1465,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1480,7 +1480,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1495,7 +1495,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1510,7 +1510,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1525,7 +1525,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1540,7 +1540,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1551,7 +1551,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1562,7 +1562,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1577,7 +1577,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36 Edg/130.0.6723.31", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36 Edg/130.0.6723.44", "screen": { "width": 1792, "height": 1120 @@ -1622,7 +1622,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1637,7 +1637,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.31 Safari/537.36 Edg/130.0.6723.31", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36 Edg/130.0.6723.44", "screen": { "width": 1920, "height": 1080 From 699f51b227d98d6d6e912f9490828337d1011340 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:18:06 -0700 Subject: [PATCH 09/29] feat(webkit): roll to r2091 (#33070) --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 7921a26035..3dbbeee561 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2090", + "revision": "2091", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From 0a63427c778ba447e870fe1ade72fee35c483fe1 Mon Sep 17 00:00:00 2001 From: Fumiaki MATSUSHIMA Date: Sun, 13 Oct 2024 20:16:01 +0900 Subject: [PATCH 10/29] fix(codegen): fix unselect issue (#33076) --- packages/playwright-core/src/server/codegen/java.ts | 2 +- packages/playwright-core/src/server/codegen/javascript.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 507a040bce..5b417c6c3a 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -122,7 +122,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { case 'navigate': return `${subject}.navigate(${quote(action.url)});`; case 'select': - return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.selectOption(${formatSelectOption(action.options.length > 1 ? action.options : action.options[0])});`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.selectOption(${formatSelectOption(action.options.length === 1 ? action.options[0] : action.options)});`; case 'assertText': return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${action.substring ? 'containsText' : 'hasText'}(${quote(action.text)});`; case 'assertChecked': diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index 558670cd47..b68a8104a8 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -106,7 +106,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { case 'navigate': return `await ${subject}.goto(${quote(action.url)});`; case 'select': - return `await ${subject}.${this._asLocator(action.selector)}.selectOption(${formatObject(action.options.length > 1 ? action.options : action.options[0])});`; + return `await ${subject}.${this._asLocator(action.selector)}.selectOption(${formatObject(action.options.length === 1 ? action.options[0] : action.options)});`; case 'assertText': return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'toContainText' : 'toHaveText'}(${quote(action.text)});`; case 'assertChecked': From 4c2d62a8816bb606e1e73b1c82fa39aa13406a70 Mon Sep 17 00:00:00 2001 From: Remigiusz Dudek Date: Mon, 14 Oct 2024 14:16:19 +0200 Subject: [PATCH 11/29] feat(html-reporter): recognize video attachment by the contentType (#33074) Closes https://github.com/microsoft/playwright/issues/33073. --------- Signed-off-by: Remigiusz Dudek Co-authored-by: Dmitry Gozman --- packages/html-reporter/src/testResultView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 273703a0c7..48a24a2391 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -70,7 +70,7 @@ export const TestResultView: React.FC<{ const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => { const attachments = result?.attachments || []; const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/'))); - const videos = attachments.filter(a => a.name === 'video'); + const videos = attachments.filter(a => a.contentType.startsWith('video/')); const traces = attachments.filter(a => a.name === 'trace'); const htmls = attachments.filter(a => a.contentType.startsWith('text/html')); const otherAttachments = new Set(attachments); From 9fcf60464d983b70b3cafea3e230e4e6e4f92c15 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 14 Oct 2024 16:32:11 +0200 Subject: [PATCH 12/29] chore: various v1.48.0 roll fixes for .NET (#33096) --- docs/src/api/class-websocketroute.md | 14 ++++++++++---- docs/src/api/params.md | 2 +- utils/doclint/dotnetXmlDocumentation.js | 13 ++++++++----- utils/doclint/generateDotnetApi.js | 5 +++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/src/api/class-websocketroute.md b/docs/src/api/class-websocketroute.md index e5347b07b1..f977050481 100644 --- a/docs/src/api/class-websocketroute.md +++ b/docs/src/api/class-websocketroute.md @@ -256,19 +256,25 @@ By default, closing one side of the connection, either in the page or on the ser ### param: WebSocketRoute.onClose.handler * since: v1.48 * langs: js, python -- `handler` <[function]\([number]|[undefined], [string]|[undefined]\): [Promise|any]> +- `handler` <[function]\([int]|[undefined], [string]|[undefined]\): [Promise|any]> Function that will handle WebSocket closure. Received an optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code) and an optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason). ### param: WebSocketRoute.onClose.handler * since: v1.48 -* langs: java, csharp -- `handler` <[function]\([null]|[number], [null]|[string]\)> +* langs: java +- `handler` <[function]\([null]|[int], [null]|[string]\)> Function that will handle WebSocket closure. Received an optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code) and an optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason). +### param: WebSocketRoute.onClose.handler +* since: v1.48 +* langs: csharp +- `handler` <[function]\([int?], [string]\)> -## async method: WebSocketRoute.onMessage +Function that will handle WebSocket closure. Received an optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code) and an optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason). + +## method: WebSocketRoute.onMessage * since: v1.48 This method allows to handle messages that are sent by the WebSocket, either from the page or from the server. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 63693030bb..9b8d3de31b 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -363,7 +363,7 @@ Target URL. ## js-fetch-option-params * langs: js -- `params` <[Object]<[string], [string]|[number]|[boolean]>|[URLSearchParams]|[string]> +- `params` <[Object]<[string], [string]|[float]|[boolean]>|[URLSearchParams]|[string]> Query parameters to be sent with the URL. diff --git a/utils/doclint/dotnetXmlDocumentation.js b/utils/doclint/dotnetXmlDocumentation.js index 66fae02c12..4b500c0781 100644 --- a/utils/doclint/dotnetXmlDocumentation.js +++ b/utils/doclint/dotnetXmlDocumentation.js @@ -16,7 +16,7 @@ // @ts-check const Documentation = require('./documentation'); -const { visitAll } = require('../markdown'); +const { visitAll, render } = require('../markdown'); /** * @param {Documentation.MarkdownNode[]} nodes * @param {number} maxColumns @@ -64,7 +64,10 @@ function _innerRenderNodes(nodes, maxColumns = 80, wrapParagraphs = true) { } else if (node.type === 'li') { _wrapInNode('item>/g, '>'); if (i < lines.length - 1) - line = line + "
"; + line = line + '
'; out.push(line); i++; } @@ -163,4 +166,4 @@ function renderTextOnly(nodes, maxColumns = 80) { return result.summary; } -module.exports = { renderXmlDoc, renderTextOnly } \ No newline at end of file +module.exports = { renderXmlDoc, renderTextOnly }; \ No newline at end of file diff --git a/utils/doclint/generateDotnetApi.js b/utils/doclint/generateDotnetApi.js index 006d6e494c..2e82f92d0a 100644 --- a/utils/doclint/generateDotnetApi.js +++ b/utils/doclint/generateDotnetApi.js @@ -520,7 +520,8 @@ function renderMethod(member, parent, name, options, out) { && !name.startsWith('Get') && name !== 'CreateFormData' && !name.startsWith('PostDataJSON') - && !name.startsWith('As')) { + && !name.startsWith('As') + && name !== 'ConnectToServer') { if (!member.async) { if (member.spec && !options.nodocs) out.push(...XmlDoc.renderXmlDoc(member.spec, maxDocumentationColumnWidth)); @@ -718,7 +719,7 @@ function translateType(type, parent, generateNameCallback = t => t.name, optiona if (type.expression === '[null]|[Error]') return 'void'; - if (type.name == 'Promise' && type.templates?.[0].name === 'any') + if (type.name === 'Promise' && type.templates?.[0].name === 'any') return 'Task'; if (type.union) { From a8df750a48fcad562b5f42e05ea554a07f80a377 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 14 Oct 2024 17:22:29 +0200 Subject: [PATCH 13/29] fix(har): account for reused sockets (#33087) Closes https://github.com/microsoft/playwright/issues/32960 If the socket is reused, the connect and DNS timings are set to -1, because that timing doesn't apply to the current request. The time between request start and the socket being free is counted as `blocked`. --- packages/playwright-core/src/server/fetch.ts | 11 +++++++++-- tests/library/har.spec.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 243e89cf1c..f231c907c0 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -302,6 +302,7 @@ export abstract class APIRequestContext extends SdkObject { const requestOptions = { ...options, agent }; const startAt = monotonicTime(); + let reusedSocketAt: number | undefined; let dnsLookupAt: number | undefined; let tcpConnectionAt: number | undefined; let tlsHandshakeAt: number | undefined; @@ -319,14 +320,15 @@ export abstract class APIRequestContext extends SdkObject { const notifyRequestFinished = (body?: Buffer) => { const endAt = monotonicTime(); // spec: http://www.softwareishard.com/blog/har-12-spec/#timings + const connectEnd = tlsHandshakeAt ?? tcpConnectionAt; const timings: har.Timings = { send: requestFinishAt! - startAt, wait: responseAt - requestFinishAt!, receive: endAt - responseAt, dns: dnsLookupAt ? dnsLookupAt - startAt : -1, - connect: (tlsHandshakeAt ?? tcpConnectionAt!) - startAt, // "If [ssl] is defined then the time is also included in the connect field " + connect: connectEnd ? connectEnd - startAt : -1, // "If [ssl] is defined then the time is also included in the connect field " ssl: tlsHandshakeAt ? tlsHandshakeAt - tcpConnectionAt! : -1, - blocked: -1, + blocked: reusedSocketAt ? reusedSocketAt - startAt : -1, }; const requestFinishedEvent: APIRequestFinishedEvent = { @@ -489,6 +491,11 @@ export abstract class APIRequestContext extends SdkObject { request.on('close', () => eventsHelper.removeEventListeners(listeners)); request.on('socket', socket => { + if (request.reusedSocket) { + reusedSocketAt = monotonicTime(); + return; + } + // happy eyeballs don't emit lookup and connect events, so we use our custom ones const happyEyeBallsTimings = timingForSocket(socket); dnsLookupAt = happyEyeBallsTimings.dnsLookupAt; diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index a5d4a6fa0e..92358608e9 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -877,6 +877,19 @@ it('should include timings when using socks proxy', async ({ contextFactory, ser expect(log.entries[0].timings.connect).toBeGreaterThan(0); }); +it('should not have connect and dns timings when socket is reused', async ({ contextFactory, server }, testInfo) => { + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.request.get(server.EMPTY_PAGE); + await page.request.get(server.EMPTY_PAGE); + + const log = await getLog(); + expect(log.entries).toHaveLength(2); + const request2 = log.entries[1]; + expect.soft(request2.timings.connect).toBe(-1); + expect.soft(request2.timings.dns).toBe(-1); + expect.soft(request2.timings.blocked).toBeGreaterThan(0); +}); + it('should include redirects from API request', async ({ contextFactory, server }, testInfo) => { server.setRedirect('/redirect-me', '/simple.json'); const { page, getLog } = await pageWithHar(contextFactory, testInfo); From c7fbeddaf479b1c0b6ce93e7ad72ac8141a91781 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Mon, 14 Oct 2024 13:21:49 -0700 Subject: [PATCH 14/29] feat(webkit): roll to r2092 (#33078) --- packages/playwright-core/browsers.json | 2 +- tests/library/modernizr.spec.ts | 4 ++-- tests/page/page-screenshot.spec.ts | 5 +++-- ...nshot-canvas-with-accurate-corners-webkit.png | Bin 0 -> 2249 bytes 4 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 tests/page/page-screenshot.spec.ts-snapshots/screenshot-canvas-with-accurate-corners-webkit.png diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 3dbbeee561..53fedf1923 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2091", + "revision": "2092", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/tests/library/modernizr.spec.ts b/tests/library/modernizr.spec.ts index d74afaedb2..7e3fc49f6f 100644 --- a/tests/library/modernizr.spec.ts +++ b/tests/library/modernizr.spec.ts @@ -57,7 +57,7 @@ it('Safari Desktop', async ({ browser, browserName, platform, server, headless } expected.publickeycredential = false; expected.mediastream = false; if (headless) - expected.todataurljpeg = false; + expected.todataurlwebp = true; // GHA delete actual.variablefonts; @@ -124,7 +124,7 @@ it('Mobile Safari', async ({ playwright, browser, browserName, platform, server, expected.publickeycredential = false; expected.mediastream = false; if (headless) - expected.todataurljpeg = false; + expected.todataurlwebp = true; // GHA delete actual.variablefonts; diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index 07e53e4a71..879307aa91 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -280,12 +280,13 @@ it.describe('page screenshot', () => { expect(screenshot).toMatchSnapshot('screenshot-clip-odd-size.png'); }); - it('should work for canvas', async ({ page, server, isElectron, isMac, macVersion, browserName, headless }) => { + it('should work for canvas', async ({ page, server, isElectron, isMac, isLinux, macVersion, browserName, headless }) => { it.fixme(isElectron && isMac, 'Fails on the bots'); await page.setViewportSize({ width: 500, height: 500 }); await page.goto(server.PREFIX + '/screenshots/canvas.html'); const screenshot = await page.screenshot(); - if (!headless && browserName === 'chromium' && isMac && os.arch() === 'arm64' && macVersion >= 14) + if ((!headless && browserName === 'chromium' && isMac && os.arch() === 'arm64' && macVersion >= 14) || + (browserName === 'webkit' && isLinux)) expect(screenshot).toMatchSnapshot('screenshot-canvas-with-accurate-corners.png'); else expect(screenshot).toMatchSnapshot('screenshot-canvas.png'); diff --git a/tests/page/page-screenshot.spec.ts-snapshots/screenshot-canvas-with-accurate-corners-webkit.png b/tests/page/page-screenshot.spec.ts-snapshots/screenshot-canvas-with-accurate-corners-webkit.png new file mode 100644 index 0000000000000000000000000000000000000000..8c38aaeeb6eda4607758f97cc07b6007c0bff3c3 GIT binary patch literal 2249 zcmeAS@N?(olHy`uVBq!ia0y~yVEh8Y9Be?5)7S2I0V&4fAa^H*b?0PW0y!+jPM#qg zKv2Vx(!s#MQS9mB7*a9k?JdVXDMJz0KdiizR>NlsY?CKf7Tz2)pShtJHJ z_aUhI{bgYWi!;cEPr85Mf7$-~{{9s!UVp6u+HHTvouzi|F;~m!_ZfbiIu2xS&D(za zCFlL1>dOoV9{*_LV_;yLZqLlHZ_{(pt-r5#upj@x{%6VbPyZPoNLw+ma6qw&{?VNa*bXc&bfTWy zK<>$2Yihh)o#8-qF%=D_Mc|E^Nxg_44a?E698CkmBMmg%w|uW%zF~pPFJN Date: Mon, 14 Oct 2024 13:25:30 -0700 Subject: [PATCH 15/29] chore(bidi): remove assertion from response dispatch (#33100) After the context has been disposed we can't route any callbacks to it because it is not in the map, so the assertion doesn't make sense as it always ends up in the top level session. Fixes the following error: ``` pw:browser Closing websocket due to failed onmessage callback. eventData={"type":"success","id":32,"result":{}} e=Assertion error Error: Assertion error pw:browser at assert (/home/yurys/playwright/packages/playwright-core/src/utils/debug.ts:21:11) pw:browser at BidiSession.dispatchMessage (/home/yurys/playwright/packages/playwright-core/src/server/bidi/bidiConnection.ts:229:13) pw:browser at BidiConnection.call [as _dispatchMessage] (/home/yurys/playwright/packages/playwright-core/src/server/bidi/bidiConnection.ts:93:25) ``` --- packages/playwright-core/src/server/bidi/bidiConnection.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiConnection.ts b/packages/playwright-core/src/server/bidi/bidiConnection.ts index f348815940..48472bf748 100644 --- a/packages/playwright-core/src/server/bidi/bidiConnection.ts +++ b/packages/playwright-core/src/server/bidi/bidiConnection.ts @@ -15,7 +15,6 @@ */ import { EventEmitter } from 'events'; -import { assert } from '../../utils'; import type { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport'; import type { RecentLogsCollector } from '../../utils/debugLogger'; import { debugLogger } from '../../utils/debugLogger'; @@ -224,7 +223,6 @@ export class BidiSession extends EventEmitter { } } else if (object.id) { // Response might come after session has been disposed and rejected all callbacks. - assert(this.isDisposed()); } else { Promise.resolve().then(() => this.emit(object.method, object.params)); } From ecd147ce436f674348b6e92a50fe82bc0ef27b0f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 14 Oct 2024 13:46:06 -0700 Subject: [PATCH 16/29] fix(test runner): when sharding with beforeAll, use shards total instead of workers (#33083) Otherwise, we might split the `beforeAll`-grouped test group into `workers` parts instead of `shard.total` parts as the user would expect. Fixes #33077. --- packages/playwright/src/runner/loadUtils.ts | 7 +++- packages/playwright/src/runner/testGroups.ts | 4 +- tests/playwright-test/shard.spec.ts | 40 ++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index 63a2307507..e2c6d0c530 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -176,8 +176,11 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho if (config.config.shard) { // Create test groups for top-level projects. const testGroups: TestGroup[] = []; - for (const projectSuite of rootSuite.suites) - testGroups.push(...createTestGroups(projectSuite, config.config.workers)); + for (const projectSuite of rootSuite.suites) { + // Split beforeAll-grouped tests into "config.shard.total" groups when needed. + // Later on, we'll re-split them between workers by using "config.workers" instead. + testGroups.push(...createTestGroups(projectSuite, config.config.shard.total)); + } // Shard test groups. const testGroupsInThisShard = filterForShard(config.config.shard, testGroups); diff --git a/packages/playwright/src/runner/testGroups.ts b/packages/playwright/src/runner/testGroups.ts index 5a70bc84cc..5743c7044b 100644 --- a/packages/playwright/src/runner/testGroups.ts +++ b/packages/playwright/src/runner/testGroups.ts @@ -24,7 +24,7 @@ export type TestGroup = { tests: TestCase[]; }; -export function createTestGroups(projectSuite: Suite, workers: number): TestGroup[] { +export function createTestGroups(projectSuite: Suite, expectedParallelism: number): TestGroup[] { // This function groups tests that can be run together. // Tests cannot be run together when: // - They belong to different projects - requires different workers. @@ -116,7 +116,7 @@ export function createTestGroups(projectSuite: Suite, workers: number): TestGrou result.push(...withRequireFile.parallel.values()); // Tests with beforeAll/afterAll should try to share workers as much as possible. - const parallelWithHooksGroupSize = Math.ceil(withRequireFile.parallelWithHooks.tests.length / workers); + const parallelWithHooksGroupSize = Math.ceil(withRequireFile.parallelWithHooks.tests.length / expectedParallelism); let lastGroup: TestGroup | undefined; for (const test of withRequireFile.parallelWithHooks.tests) { if (!lastGroup || lastGroup.tests.length >= parallelWithHooksGroupSize) { diff --git a/tests/playwright-test/shard.spec.ts b/tests/playwright-test/shard.spec.ts index e82a434f52..f73462c1f4 100644 --- a/tests/playwright-test/shard.spec.ts +++ b/tests/playwright-test/shard.spec.ts @@ -284,3 +284,43 @@ test('should not shard mode:default suites', async ({ runInlineTest }) => { expect(result.outputLines).toEqual(['beforeAll2', 'test4', 'test5']); } }); + +test('should shard tests with beforeAll based on shards total instead of workers', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33077' }, +}, async ({ runInlineTest }) => { + const tests = { + 'a.spec.ts': ` + import { test } from '@playwright/test'; + + test.describe.configure({ mode: 'parallel' }); + test.beforeAll(() => { + console.log('\\n%%beforeAll'); + }); + + for (let i = 1; i <= 8; i++) { + test('test ' + i, async ({ }) => { + console.log('\\n%%test' + i); + }); + } + `, + }; + + { + const result = await runInlineTest(tests, { shard: '1/4', workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + expect(result.outputLines).toEqual(['beforeAll', 'test1', 'test2']); + } + { + const result = await runInlineTest(tests, { shard: '2/4', workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + expect(result.outputLines).toEqual(['beforeAll', 'test3', 'test4']); + } + { + const result = await runInlineTest(tests, { shard: '7/8', workers: 6 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.outputLines).toEqual(['beforeAll', 'test7']); + } +}); From 6cfcbe0d6d7447f8129b083c828d187846773fb6 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 14 Oct 2024 14:04:24 -0700 Subject: [PATCH 17/29] chore: fix codegen selector while debugging (#33099) Fixes #33052 --- .../playwright-core/src/server/recorder.ts | 3 ++- .../src/server/recorder/recorderApp.ts | 6 ++--- .../src/server/recorder/recorderFrontend.ts | 2 +- .../server/recorder/recorderInTraceViewer.ts | 4 ++-- packages/recorder/src/recorder.tsx | 18 +++++++------- packages/recorder/src/recorderTypes.ts | 2 +- packages/web/src/components/sourceChooser.tsx | 24 +++++++++++-------- packages/web/src/components/toolbarButton.tsx | 3 +++ tests/library/inspector/pause.spec.ts | 16 +++++++++++++ 9 files changed, 50 insertions(+), 28 deletions(-) diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 4aab712b9b..386e4dece6 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -140,6 +140,7 @@ export class Recorder implements InstrumentationListener, IRecorder { this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], actions: actions.ActionInContext[] }) => { this._recorderSources = data.sources; recorderApp.setActions(data.actions, data.sources); + recorderApp.setRunningFile(undefined); this._pushAllSources(); }); @@ -299,7 +300,7 @@ export class Recorder implements InstrumentationListener, IRecorder { } this._pushAllSources(); if (fileToSelect) - this._recorderApp?.setFile(fileToSelect); + this._recorderApp?.setRunningFile(fileToSelect); } private _pushAllSources() { diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 30149f9816..3f9a636579 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -34,7 +34,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} async setMode(mode: Mode): Promise {} - async setFile(file: string): Promise {} + async setRunningFile(file: string | undefined): Promise {} async setSelector(selector: string, userGesture?: boolean): Promise {} async updateCallLogs(callLogs: CallLog[]): Promise {} async setSources(sources: Source[]): Promise {} @@ -131,9 +131,9 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }).toString(), { isFunction: true }, mode).catch(() => {}); } - async setFile(file: string): Promise { + async setRunningFile(file: string | undefined): Promise { await this._page.mainFrame().evaluateExpression(((file: string) => { - window.playwrightSetFile(file); + window.playwrightSetRunningFile(file); }).toString(), { isFunction: true }, file).catch(() => {}); } diff --git a/packages/playwright-core/src/server/recorder/recorderFrontend.ts b/packages/playwright-core/src/server/recorder/recorderFrontend.ts index 97df1d3ceb..9bf48022a7 100644 --- a/packages/playwright-core/src/server/recorder/recorderFrontend.ts +++ b/packages/playwright-core/src/server/recorder/recorderFrontend.ts @@ -28,7 +28,7 @@ export interface IRecorderApp extends EventEmitter { close(): Promise; setPaused(paused: boolean): Promise; setMode(mode: Mode): Promise; - setFile(file: string): Promise; + setRunningFile(file: string | undefined): Promise; setSelector(selector: string, userGesture?: boolean): Promise; updateCallLogs(callLogs: CallLog[]): Promise; setSources(sources: Source[]): Promise; diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts index ab67fe562c..e11ef1283a 100644 --- a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -66,8 +66,8 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp this._transport.deliverEvent('setMode', { mode }); } - async setFile(file: string): Promise { - this._transport.deliverEvent('setFileIfNeeded', { file }); + async setRunningFile(file: string | undefined): Promise { + this._transport.deliverEvent('setRunningFile', { file }); } async setSelector(selector: string, userGesture?: boolean): Promise { diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 9d5c0feeba..19b7bc12a7 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -41,13 +41,11 @@ export const Recorder: React.FC = ({ log, mode, }) => { - const [fileId, setFileId] = React.useState(); + const [selectedFileId, setSelectedFileId] = React.useState(); + const [runningFileId, setRunningFileId] = React.useState(); const [selectedTab, setSelectedTab] = React.useState('log'); - React.useEffect(() => { - if (!fileId && sources.length > 0) - setFileId(sources[0].id); - }, [fileId, sources]); + const fileId = selectedFileId || runningFileId || sources[0]?.id; const source = React.useMemo(() => { if (fileId) { @@ -66,7 +64,7 @@ export const Recorder: React.FC = ({ setLocator(asLocator(language, selector)); }; - window.playwrightSetFile = setFileId; + window.playwrightSetRunningFile = setRunningFileId; const messagesEndRef = React.useRef(null); React.useLayoutEffect(() => { @@ -134,19 +132,19 @@ export const Recorder: React.FC = ({ { copy(source.text); }}> - { + { window.dispatch({ event: 'resume' }); }}> - { + { window.dispatch({ event: 'pause' }); }}> - { + { window.dispatch({ event: 'step' }); }}>
Target:
{ - setFileId(fileId); + setSelectedFileId(fileId); window.dispatch({ event: 'fileChanged', params: { file: fileId } }); }} /> { diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index a5791e2306..dd379f7ccd 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -96,7 +96,7 @@ declare global { playwrightSetSources: (sources: Source[]) => void; playwrightSetOverlayVisible: (visible: boolean) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void; - playwrightSetFile: (file: string) => void; + playwrightSetRunningFile: (file: string | undefined) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void; playwrightSourcesEchoForTest: Source[]; dispatch(data: any): Promise; diff --git a/packages/web/src/components/sourceChooser.tsx b/packages/web/src/components/sourceChooser.tsx index 0645480a03..22b91c61a5 100644 --- a/packages/web/src/components/sourceChooser.tsx +++ b/packages/web/src/components/sourceChooser.tsx @@ -22,7 +22,7 @@ export const SourceChooser: React.FC<{ fileId: string | undefined, setFileId: (fileId: string) => void, }> = ({ sources, fileId, setFileId }) => { - return { setFileId(event.target.selectedOptions[0].value); }}>{renderSourceOptions(sources)}; }; @@ -33,17 +33,21 @@ function renderSourceOptions(sources: Source[]): React.ReactNode { ); - const hasGroup = sources.some(s => s.group); - if (hasGroup) { - const groups = new Set(sources.map(s => s.group)); - return [...groups].filter(Boolean).map(group => ( - - {sources.filter(s => s.group === group).map(source => renderOption(source))} - - )); + const sourcesByGroups = new Map(); + for (const source of sources) { + let list = sourcesByGroups.get(source.group || 'Debugger'); + if (!list) { + list = []; + sourcesByGroups.set(source.group || 'Debugger', list); + } + list.push(source); } - return sources.map(source => renderOption(source)); + return [...sourcesByGroups.entries()].map(([group, sources]) => ( + + {sources.filter(s => (s.group || 'Debugger') === group).map(source => renderOption(source))} + + )); } export function emptySource(): Source { diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index 00b9babd59..184642b395 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -28,6 +28,7 @@ export interface ToolbarButtonProps { style?: React.CSSProperties, testId?: string, className?: string, + ariaLabel?: string, } export const ToolbarButton: React.FC> = ({ @@ -40,6 +41,7 @@ export const ToolbarButton: React.FC style, testId, className, + ariaLabel, }) => { return `; - case 'link': return `${escapedTextContent}`; - case 'textbox': return ``; - } - return `
${escapedTextContent}
`; -} diff --git a/packages/playwright/ThirdPartyNotices.txt b/packages/playwright/ThirdPartyNotices.txt index f2bb64d661..46c2f60cd2 100644 --- a/packages/playwright/ThirdPartyNotices.txt +++ b/packages/playwright/ThirdPartyNotices.txt @@ -155,6 +155,7 @@ This project incorporates components from the projects listed below. The origina - undici-types@6.19.8 (https://github.com/nodejs/undici) - update-browserslist-db@1.0.13 (https://github.com/browserslist/update-db) - yallist@3.1.1 (https://github.com/isaacs/yallist) +- yaml@2.5.1 (https://github.com/eemeli/yaml) %% @ampproject/remapping@2.2.1 NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -4397,8 +4398,26 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ========================================= END OF yallist@3.1.1 AND INFORMATION +%% yaml@2.5.1 NOTICES AND INFORMATION BEGIN HERE +========================================= +Copyright Eemeli Aro + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +========================================= +END OF yaml@2.5.1 AND INFORMATION + SUMMARY BEGIN HERE ========================================= -Total Packages: 151 +Total Packages: 152 ========================================= END OF SUMMARY \ No newline at end of file diff --git a/packages/playwright/bundles/expect/src/expectBundleImpl.ts b/packages/playwright/bundles/expect/src/expectBundleImpl.ts index dbfd169353..875b48e614 100644 --- a/packages/playwright/bundles/expect/src/expectBundleImpl.ts +++ b/packages/playwright/bundles/expect/src/expectBundleImpl.ts @@ -40,6 +40,7 @@ export const matcherUtils = { }; export { + EXPECTED_COLOR, INVERTED_COLOR, RECEIVED_COLOR, printReceived, diff --git a/packages/playwright/bundles/utils/package-lock.json b/packages/playwright/bundles/utils/package-lock.json index fcf9f972fe..90df9a258b 100644 --- a/packages/playwright/bundles/utils/package-lock.json +++ b/packages/playwright/bundles/utils/package-lock.json @@ -13,7 +13,8 @@ "json5": "2.2.3", "pirates": "4.0.4", "source-map-support": "0.5.21", - "stoppable": "1.1.0" + "stoppable": "1.1.0", + "yaml": "^2.5.1" }, "devDependencies": { "@types/source-map-support": "^0.5.4", @@ -280,6 +281,17 @@ "engines": { "node": ">=8.0" } + }, + "node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } } }, "dependencies": { @@ -464,6 +476,11 @@ "requires": { "is-number": "^7.0.0" } + }, + "yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==" } } } diff --git a/packages/playwright/bundles/utils/package.json b/packages/playwright/bundles/utils/package.json index 69477909c5..dc807c0d95 100644 --- a/packages/playwright/bundles/utils/package.json +++ b/packages/playwright/bundles/utils/package.json @@ -14,7 +14,8 @@ "json5": "2.2.3", "pirates": "4.0.4", "source-map-support": "0.5.21", - "stoppable": "1.1.0" + "stoppable": "1.1.0", + "yaml": "^2.5.1" }, "devDependencies": { "@types/source-map-support": "^0.5.4", diff --git a/packages/playwright/bundles/utils/src/utilsBundleImpl.ts b/packages/playwright/bundles/utils/src/utilsBundleImpl.ts index 7c29c301a8..76cf961ab7 100644 --- a/packages/playwright/bundles/utils/src/utilsBundleImpl.ts +++ b/packages/playwright/bundles/utils/src/utilsBundleImpl.ts @@ -31,3 +31,6 @@ export const enquirer = enquirerLibrary; import chokidarLibrary from 'chokidar'; export const chokidar = chokidarLibrary; + +import yamlLibrary from 'yaml'; +export const yaml = yamlLibrary; diff --git a/packages/playwright/src/matchers/DEPS.list b/packages/playwright/src/matchers/DEPS.list index 59b704628d..de39c6b545 100644 --- a/packages/playwright/src/matchers/DEPS.list +++ b/packages/playwright/src/matchers/DEPS.list @@ -1,4 +1,5 @@ [*] ../common/ ../util.ts +../utilsBundle.ts ../worker/testInfo.ts diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 16300607d9..0d276d4101 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -62,6 +62,7 @@ import { import { zones } from 'playwright-core/lib/utils'; import { TestInfoImpl } from '../worker/testInfo'; import { ExpectError, isExpectError } from './matcherHint'; +import { toMatchAriaSnapshot } from './toMatchAriaSnapshot'; // #region // Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts @@ -236,6 +237,7 @@ const customAsyncMatchers = { toHaveValue, toHaveValues, toHaveScreenshot, + toMatchAriaSnapshot, toPass, }; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 3ca9180ae2..c0319e371f 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -27,7 +27,7 @@ import { TestInfoImpl } from '../worker/testInfo'; import type { ExpectMatcherState } from '../../types/test'; import { takeFirst } from '../common/config'; -interface LocatorEx extends Locator { +export interface LocatorEx extends Locator { _expect(expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; } diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts new file mode 100644 index 0000000000..949e44af6f --- /dev/null +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -0,0 +1,134 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import type { LocatorEx } from './matchers'; +import type { ExpectMatcherState } from '../../types/test'; +import { kNoElementsFoundError, matcherHint, type MatcherResult } from './matcherHint'; +import type { AriaTemplateNode } from 'playwright-core/lib/server/injected/ariaSnapshot'; +import { yaml } from '../utilsBundle'; +import { colors } from 'playwright-core/lib/utilsBundle'; +import { EXPECTED_COLOR } from '../common/expectBundle'; +import { callLogText } from '../util'; +import { printReceivedStringContainExpectedSubstring } from './expect'; + +export async function toMatchAriaSnapshot( + this: ExpectMatcherState, + receiver: LocatorEx, + expected: string, + options: { timeout?: number, matchSubstring?: boolean } = {}, +): Promise> { + const matcherName = 'toMatchAriaSnapshot'; + + const matcherOptions = { + isNot: this.isNot, + promise: this.promise, + }; + + if (typeof expected !== 'string') { + throw new Error([ + matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions), + `${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a string`, + this.utils.printWithType('Expected', expected, this.utils.printExpected) + ].join('\n\n')); + } + + const ariaTree = toAriaTree(expected) as AriaTemplateNode; + const timeout = options.timeout ?? this.timeout; + const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: ariaTree, isNot: this.isNot, timeout }); + + const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); + const notFound = received === kNoElementsFoundError; + const message = () => { + if (pass) { + if (notFound) + return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log); + const printedReceived = printReceivedStringContainExpectedSubstring(received, received.indexOf(expected), expected.length); + return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log); + } else { + const labelExpected = `Expected`; + if (notFound) + return messagePrefix + `${labelExpected}: ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log); + return messagePrefix + this.utils.printDiffOrStringify(expected, received, labelExpected, 'Received string', false) + callLogText(log); + } + }; + + return { + name: matcherName, + expected, + message, + pass, + actual: received, + log, + timeout: timedOut ? timeout : undefined, + }; +} + +function parseKey(key: string): AriaTemplateNode { + if (!key) + return { role: '' }; + + const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/); + + if (!match) + throw new Error(`Invalid key ${key}`); + + const role = match[1]; + if (role && role !== 'text' && !allRoles.includes(role)) + throw new Error(`Invalid role ${role}`); + + if (match[2]) + return { role, name: match[2] }; + if (match[3]) + return { role, name: new RegExp(match[3]) }; + return { role }; +} + +function valueOrRegex(value: string): string | RegExp { + return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : value; +} + +type YamlNode = Record | string>; + +function toAriaTree(text: string): AriaTemplateNode { + const convert = (object: YamlNode | string): AriaTemplateNode | RegExp | string => { + const key = typeof object === 'string' ? object : Object.keys(object)[0]; + const value = typeof object === 'string' ? undefined : object[key]; + const parsed = parseKey(key); + if (parsed.role === 'text') { + if (typeof value !== 'string') + throw new Error(`Generic role must have a text value`); + return valueOrRegex(value as string); + } + if (Array.isArray(value)) + parsed.children = value.map(convert); + else if (value) + parsed.children = [valueOrRegex(value)]; + return parsed; + }; + const fragment = yaml.parse(text) as YamlNode[]; + return convert({ '': fragment }) as AriaTemplateNode; +} + +const allRoles = [ + 'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'command', + 'complementary', 'composite', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid', + 'gridcell', 'group', 'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'meter', 'menu', + 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup', + 'range', 'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'section', 'sectionhead', 'select', 'separator', 'slider', + 'spinbutton', 'status', 'strong', 'structure', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', + 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'widget', 'window' +]; diff --git a/packages/playwright/src/utilsBundle.ts b/packages/playwright/src/utilsBundle.ts index 072e16bb03..5ded7993b7 100644 --- a/packages/playwright/src/utilsBundle.ts +++ b/packages/playwright/src/utilsBundle.ts @@ -20,3 +20,4 @@ export const sourceMapSupport: typeof import('../bundles/utils/node_modules/@typ export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable; export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer; export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar; +export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index a1128c519e..3f1179e34f 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7638,6 +7638,31 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * Asserts that the target element matches the given accessibility snapshot. + * + * **Usage** + * + * ```js + * import { role as x } from '@playwright/test'; + * // ... + * await page.goto('https://demo.playwright.dev/todomvc/'); + * await expect(page.locator('body')).toMatchAriaSnapshot(` + * - heading "todos" + * - textbox "What needs to be done?" + * `); + * ``` + * + * @param expected + * @param options + */ + toMatchAriaSnapshot(expected: string, options?: { + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * Makes the assertion check for the opposite condition. For example, this code tests that the Locator doesn't contain * text `"error"`: diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index 51d2262bf8..0305f5c5e3 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -107,6 +107,7 @@ it('expected properties on playwright object', async ({ page }) => { 'inspect', 'selector', 'generateLocator', + 'ariaSnapshot', 'resume', 'locator', 'getByTestId', diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts new file mode 100644 index 0000000000..d1e0d7b91e --- /dev/null +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * 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 { stripAnsi } from 'tests/config/utils'; +import { test, expect } from './pageTest'; + +test('should match', async ({ page }) => { + await page.setContent(`

title

`); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "title" + `); +}); + +test('should match in list', async ({ page }) => { + await page.setContent(` +

title

+

title 2

+ `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "title" + `); +}); + +test('should match list with accessible name', async ({ page }) => { + await page.setContent(` +
    +
  • one
  • +
  • two
  • +
+ `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list "my list": + - listitem: one + - listitem: two + `); +}); + +test('should match deep item', async ({ page }) => { + await page.setContent(` +
+

title

+

title 2

+
+ `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "title" + `); +}); + +test('should match complex', async ({ page }) => { + await page.setContent(` + + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - listitem: + - link "link" + `); +}); + +test('should match regex', async ({ page }) => { + await page.setContent(`

Issues 12

`); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading /Issues \\d+/ + `); +}); + +test('should allow text nodes', async ({ page }) => { + await page.setContent(` +

Microsoft

+
Open source projects and samples from Microsoft
+ `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "Microsoft" + - text: Open source projects and samples from Microsoft + `); +}); + +test('integration test', async ({ page, browserName }) => { + test.fixme(browserName === 'webkit'); + await page.setContent(` +

Microsoft

+
Open source projects and samples from Microsoft
+ `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "Microsoft" + - text: Open source projects and samples from Microsoft + - list: + - listitem: + - group: Verified + - listitem: + - link "Sponsor" + `); +}); + +test('integration test 2', async ({ page }) => { + await page.setContent(` +
+
+

todos

+ +
+
`); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "todos" + - textbox "What needs to be done?" + `); +}); + +test('expected formatter', async ({ page }) => { + await page.setContent(` +
+
+

todos

+ +
+
`); + const error = await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "todos" + - textbox "Wrong text" + `, { timeout: 1 }).catch(e => e); + expect(stripAnsi(error.message)).toContain(`- Expected - 3 ++ Received string + 3 + +- ++ - : ++ - banner: + - heading "todos" +- - textbox "Wrong text" +- ++ - textbox "What needs to be done?"`); +}); diff --git a/utils/doclint/linting-code-snippets/cli.js b/utils/doclint/linting-code-snippets/cli.js index 146646a1f0..5d3200aa9e 100644 --- a/utils/doclint/linting-code-snippets/cli.js +++ b/utils/doclint/linting-code-snippets/cli.js @@ -152,6 +152,7 @@ class JSLintingService extends LintingService { 'notice/notice': 'off', '@typescript-eslint/no-unused-vars': 'off', 'max-len': ['error', { code: 100 }], + 'react/react-in-jsx-scope': 'off', }, } }); From 2c05d294a8ea599e3db4f4bc55047e64bb3a2bd0 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 14 Oct 2024 15:55:21 -0700 Subject: [PATCH 19/29] chore: fix webkit visibility check in aria matcher (#33102) --- .../src/server/injected/ariaSnapshot.ts | 8 ++------ tests/page/to-match-aria-snapshot.spec.ts | 14 +++++++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index b573569f8f..8e08ae7016 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -16,7 +16,7 @@ import { escapeWithQuotes } from '@isomorphic/stringUtils'; import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, isElementIgnoredForAria } from './roleUtils'; -import { isElementVisible } from './domUtils'; +import { isElementVisible, isElementStyleVisibilityVisible } from './domUtils'; type AriaNode = { role: string; @@ -59,11 +59,7 @@ export function generateAriaTree(rootElement: Element): AriaNode { return; const visible = isElementVisible(element); - const hasVisibleChildren = element.checkVisibility({ - opacityProperty: true, - visibilityProperty: true, - contentVisibilityAuto: true - }); + const hasVisibleChildren = isElementStyleVisibilityVisible(element); if (!hasVisibleChildren) return; diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index d1e0d7b91e..826f8cc90e 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -94,8 +94,20 @@ test('should allow text nodes', async ({ page }) => { `); }); +test('details visibility', async ({ page, browserName }) => { + await page.setContent(` +
+ Summary +
Details
+
+ `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - group: Summary + `); +}); + test('integration test', async ({ page, browserName }) => { - test.fixme(browserName === 'webkit'); await page.setContent(`

Microsoft

Open source projects and samples from Microsoft
From 8a275e5a5bd6b49d1e7fb8cc330884434375c4ea Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 15 Oct 2024 02:07:03 -0700 Subject: [PATCH 20/29] docs: improve docs for WebSocketRoute (#33097) --- docs/src/api/class-websocketroute.md | 73 ++++++++++++- docs/src/mock.md | 119 ++++++++++++++++++++++ docs/src/network.md | 6 +- packages/playwright-core/types/types.d.ts | 14 ++- 4 files changed, 204 insertions(+), 8 deletions(-) diff --git a/docs/src/api/class-websocketroute.md b/docs/src/api/class-websocketroute.md index f977050481..b827db25dd 100644 --- a/docs/src/api/class-websocketroute.md +++ b/docs/src/api/class-websocketroute.md @@ -8,7 +8,7 @@ Whenever a [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSoc By default, the routed WebSocket will not connect to the server. This way, you can mock entire communcation over the WebSocket. Here is an example that responds to a `"request"` with a `"response"`. ```js -await page.routeWebSocket('/ws', ws => { +await page.routeWebSocket('wss://example.com/ws', ws => { ws.onMessage(message => { if (message === 'request') ws.send('response'); @@ -17,7 +17,7 @@ await page.routeWebSocket('/ws', ws => { ``` ```java -page.routeWebSocket("/ws", ws -> { +page.routeWebSocket("wss://example.com/ws", ws -> { ws.onMessage(message -> { if ("request".equals(message)) ws.send("response"); @@ -30,7 +30,7 @@ def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): if message == "request": ws.send("response") -await page.route_web_socket("/ws", lambda ws: ws.on_message( +await page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message( lambda message: message_handler(ws, message) )) ``` @@ -40,13 +40,13 @@ def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): if message == "request": ws.send("response") -page.route_web_socket("/ws", lambda ws: ws.on_message( +page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message( lambda message: message_handler(ws, message) )) ``` ```csharp -await page.RouteWebSocketAsync("/ws", ws => { +await page.RouteWebSocketAsync("wss://example.com/ws", ws => { ws.OnMessage(message => { if (message == "request") ws.Send("response"); @@ -56,6 +56,69 @@ await page.RouteWebSocketAsync("/ws", ws => { Since we do not call [`method: WebSocketRoute.connectToServer`] inside the WebSocket route handler, Playwright assumes that WebSocket will be mocked, and opens the WebSocket inside the page automatically. +Here is another example that handles JSON messages: + +```js +await page.routeWebSocket('wss://example.com/ws', ws => { + ws.onMessage(message => { + const json = JSON.parse(message); + if (json.request === 'question') + ws.send(JSON.stringify({ response: 'answer' })); + }); +}); +``` + +```java +page.routeWebSocket("wss://example.com/ws", ws -> { + ws.onMessage(message -> { + JsonObject json = new JsonParser().parse(message).getAsJsonObject(); + if ("question".equals(json.get("request").getAsString())) { + Map result = new HashMap(); + result.put("response", "answer"); + ws.send(gson.toJson(result)); + } + }); +}); +``` + +```python async +def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): + json_message = json.loads(message) + if json_message["request"] == "question": + ws.send(json.dumps({ "response": "answer" })) + +await page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message( + lambda message: message_handler(ws, message) +)) +``` + +```python sync +def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): + json_message = json.loads(message) + if json_message["request"] == "question": + ws.send(json.dumps({ "response": "answer" })) + +page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message( + lambda message: message_handler(ws, message) +)) +``` + +```csharp +await page.RouteWebSocketAsync("wss://example.com/ws", ws => { + ws.OnMessage(message => { + using var jsonDoc = JsonDocument.Parse(message); + JsonElement root = jsonDoc.RootElement; + if (root.TryGetProperty("request", out JsonElement requestElement) && requestElement.GetString() == "question") + { + var response = new Dictionary { ["response"] = "answer" }; + string jsonResponse = JsonSerializer.Serialize(response); + ws.Send(jsonResponse); + } + }); +}); +``` + + **Intercepting** Alternatively, you may want to connect to the actual server, but intercept messages in-between and modify or block them. Calling [`method: WebSocketRoute.connectToServer`] returns a server-side `WebSocketRoute` instance that you can send messages to, or handle incoming messages. diff --git a/docs/src/mock.md b/docs/src/mock.md index 5c87e91d5b..468690904a 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -435,3 +435,122 @@ pwsh bin/Debug/netX/playwright.ps1 open --save-har=example.har --save-har-glob=" ``` Read more about [advanced networking](./network.md). + +## Mock WebSockets + +The following code will intercept WebSocket connections and mock entire communcation over the WebSocket, instead of connecting to the server. This example responds to a `"request"` with a `"response"`. + +```js +await page.routeWebSocket('wss://example.com/ws', ws => { + ws.onMessage(message => { + if (message === 'request') + ws.send('response'); + }); +}); +``` + +```java +page.routeWebSocket("wss://example.com/ws", ws -> { + ws.onMessage(message -> { + if ("request".equals(message)) + ws.send("response"); + }); +}); +``` + +```python async +def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): + if message == "request": + ws.send("response") + +await page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message( + lambda message: message_handler(ws, message) +)) +``` + +```python sync +def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): + if message == "request": + ws.send("response") + +page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message( + lambda message: message_handler(ws, message) +)) +``` + +```csharp +await page.RouteWebSocketAsync("wss://example.com/ws", ws => { + ws.OnMessage(message => { + if (message == "request") + ws.Send("response"); + }); +}); +``` + +Alternatively, you may want to connect to the actual server, but intercept messages in-between and modify or block them. Here is an example that modifies some of the messages sent by the page to the server, and leaves the rest unmodified. + +```js +await page.routeWebSocket('wss://example.com/ws', ws => { + const server = ws.connectToServer(); + ws.onMessage(message => { + if (message === 'request') + server.send('request2'); + else + server.send(message); + }); +}); +``` + +```java +page.routeWebSocket("wss://example.com/ws", ws -> { + WebSocketRoute server = ws.connectToServer(); + ws.onMessage(message -> { + if ("request".equals(message)) + server.send("request2"); + else + server.send(message); + }); +}); +``` + +```python async +def message_handler(server: WebSocketRoute, message: Union[str, bytes]): + if message == "request": + server.send("request2") + else: + server.send(message) + +def handler(ws: WebSocketRoute): + server = ws.connect_to_server() + ws.on_message(lambda message: message_handler(server, message)) + +await page.route_web_socket("wss://example.com/ws", handler) +``` + +```python sync +def message_handler(server: WebSocketRoute, message: Union[str, bytes]): + if message == "request": + server.send("request2") + else: + server.send(message) + +def handler(ws: WebSocketRoute): + server = ws.connect_to_server() + ws.on_message(lambda message: message_handler(server, message)) + +page.route_web_socket("wss://example.com/ws", handler) +``` + +```csharp +await page.RouteWebSocketAsync("wss://example.com/ws", ws => { + var server = ws.ConnectToServer(); + ws.OnMessage(message => { + if (message == "request") + server.Send("request2"); + else + server.Send(message); + }); +}); +``` + +For more details, see [WebSocketRoute]. diff --git a/docs/src/network.md b/docs/src/network.md index 33c012d9b5..97c637a806 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -10,7 +10,7 @@ Playwright provides APIs to **monitor** and **modify** browser network traffic, ## Mock APIs -Check out our [API mocking guide](./mock.md) to learn more on how to +Check out our [API mocking guide](./mock.md) to learn more on how to - mock API requests and never hit the API - perform the API request and modify the response - use HAR files to mock network requests. @@ -723,7 +723,9 @@ Important notes: ## WebSockets -Playwright supports [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) inspection out of the box. Every time a WebSocket is created, the [`event: Page.webSocket`] event is fired. This event contains the [WebSocket] instance for further web socket frames inspection: +Playwright supports [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) inspection, mocking and modifying out of the box. See our [API mocking guide](./mock.md#mock-websockets) to learn how to mock WebSockets. + +Every time a WebSocket is created, the [`event: Page.webSocket`] event is fired. This event contains the [WebSocket] instance for further web socket frames inspection: ```js page.on('websocket', ws => { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index abf92c0141..7d1d736a13 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -15356,7 +15356,7 @@ export interface CDPSession { * the WebSocket. Here is an example that responds to a `"request"` with a `"response"`. * * ```js - * await page.routeWebSocket('/ws', ws => { + * await page.routeWebSocket('wss://example.com/ws', ws => { * ws.onMessage(message => { * if (message === 'request') * ws.send('response'); @@ -15369,6 +15369,18 @@ export interface CDPSession { * inside the WebSocket route handler, Playwright assumes that WebSocket will be mocked, and opens the WebSocket * inside the page automatically. * + * Here is another example that handles JSON messages: + * + * ```js + * await page.routeWebSocket('wss://example.com/ws', ws => { + * ws.onMessage(message => { + * const json = JSON.parse(message); + * if (json.request === 'question') + * ws.send(JSON.stringify({ response: 'answer' })); + * }); + * }); + * ``` + * * **Intercepting** * * Alternatively, you may want to connect to the actual server, but intercept messages in-between and modify or block From 17837e564d1f1b975fcab7893708b6917b7340cf Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 15 Oct 2024 02:08:27 -0700 Subject: [PATCH 21/29] fix(routeWebSocket): make sure ws url without trailing slash is supported (#33095) --- .../src/server/injected/webSocketMock.ts | 1 + tests/library/route-web-socket.spec.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/playwright-core/src/server/injected/webSocketMock.ts b/packages/playwright-core/src/server/injected/webSocketMock.ts index 69d6bc0585..7a4742bac3 100644 --- a/packages/playwright-core/src/server/injected/webSocketMock.ts +++ b/packages/playwright-core/src/server/injected/webSocketMock.ts @@ -143,6 +143,7 @@ export function inject(globalThis: GlobalThis) { this.url = typeof url === 'string' ? url : url.href; try { + this.url = new URL(url).href; this._origin = new URL(url).origin; } catch { } diff --git a/tests/library/route-web-socket.spec.ts b/tests/library/route-web-socket.spec.ts index 46a75cdefb..6af0cb4bc0 100644 --- a/tests/library/route-web-socket.spec.ts +++ b/tests/library/route-web-socket.spec.ts @@ -508,3 +508,27 @@ test('should throw when connecting twice', async ({ page, server }) => { const error = await promise; expect(error.message).toContain('Already connected to the server'); }); + +test('should work with no trailing slash', async ({ page, server }) => { + const log: string[] = []; + // No trailing slash! + await page.routeWebSocket('ws://localhost:' + server.PORT, ws => { + ws.onMessage(message => { + log.push(message as string); + ws.send('response'); + }); + }); + + await page.goto('about:blank'); + await page.evaluate(({ port }) => { + window.log = []; + // No trailing slash! + window.ws = new WebSocket('ws://localhost:' + port); + window.ws.addEventListener('message', event => window.log.push(event.data)); + }, { port: server.PORT }); + + await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(1); + await page.evaluate(() => window.ws.send('query')); + await expect.poll(() => log).toEqual(['query']); + expect(await page.evaluate(() => window.log)).toEqual(['response']); +}); From 59a50cf59603850c645830a17ccb05178eb96f50 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 15 Oct 2024 02:13:19 -0700 Subject: [PATCH 22/29] fix(chromium): disable PlzDedicatedWorker again (#33110) --- .../src/server/chromium/chromiumSwitches.ts | 3 ++- tests/page/interception.spec.ts | 1 + tests/page/workers.spec.ts | 14 +++++++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/chromiumSwitches.ts b/packages/playwright-core/src/server/chromium/chromiumSwitches.ts index 01df7a66ac..4774c13ed7 100644 --- a/packages/playwright-core/src/server/chromium/chromiumSwitches.ts +++ b/packages/playwright-core/src/server/chromium/chromiumSwitches.ts @@ -37,7 +37,8 @@ export const chromiumSwitches = [ // PaintHolding - https://github.com/microsoft/playwright/issues/28023 // ThirdPartyStoragePartitioning - https://github.com/microsoft/playwright/issues/32230 // LensOverlay - Hides the Lens feature in the URL address bar. Its not working in unofficial builds. - '--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,LensOverlay', + // PlzDedicatedWorker - https://github.com/microsoft/playwright/issues/31747 + '--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,LensOverlay,PlzDedicatedWorker', '--allow-pre-commit-input', '--disable-hang-monitor', '--disable-ipc-flooding-protection', diff --git a/tests/page/interception.spec.ts b/tests/page/interception.spec.ts index 9447a80fcd..eef67e8b37 100644 --- a/tests/page/interception.spec.ts +++ b/tests/page/interception.spec.ts @@ -126,6 +126,7 @@ it('should intercept worker requests when enabled after worker creation', { }, async ({ page, server, isAndroid, browserName, browserMajorVersion }) => { it.skip(isAndroid); it.skip(browserName === 'chromium' && browserMajorVersion < 130, 'fixed in Chromium 130'); + it.fixme(browserName === 'chromium', 'requires PlzDedicatedWorker to be enabled'); await page.goto(server.EMPTY_PAGE); server.setRoute('/data_for_worker', (req, res) => res.end('failed to intercept')); diff --git a/tests/page/workers.spec.ts b/tests/page/workers.spec.ts index 327a2ed45d..3ca56a6866 100644 --- a/tests/page/workers.spec.ts +++ b/tests/page/workers.spec.ts @@ -167,7 +167,6 @@ it('should report network activity', async function({ page, server, browserName, it('should report network activity on worker creation', async function({ page, server, browserName, browserMajorVersion }) { it.skip(browserName === 'firefox' && browserMajorVersion < 114, 'https://github.com/microsoft/playwright/issues/21760'); - // Chromium needs waitForDebugger enabled for this one. await page.goto(server.EMPTY_PAGE); const url = server.PREFIX + '/one-style.css'; const requestPromise = page.waitForRequest(url); @@ -182,6 +181,19 @@ it('should report network activity on worker creation', async function({ page, s expect(response.ok()).toBe(true); }); +it('should report worker script as network request', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33107' }, +}, async function({ page, server }) { + await page.goto(server.EMPTY_PAGE); + const [request1, request2] = await Promise.all([ + page.waitForEvent('request', r => r.url().includes('worker.js')), + page.waitForEvent('requestfinished', r => r.url().includes('worker.js')), + page.evaluate(() => (window as any).w = new Worker('/worker/worker.js')), + ]); + expect.soft(request1.url()).toBe(server.PREFIX + '/worker/worker.js'); + expect.soft(request1).toBe(request2); +}); + it('should dispatch console messages when page has workers', async function({ page, server }) { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/15550' }); await page.goto(server.EMPTY_PAGE); From 615f1dbd635f9d5b8217dad9eac16c9651c2fe4c Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 15 Oct 2024 08:51:57 -0700 Subject: [PATCH 23/29] feat(chromium-tip-of-tree): roll to r1269 (#33117) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 53fedf1923..a579acc365 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1268", + "revision": "1269", "installByDefault": false, - "browserVersion": "131.0.6768.0" + "browserVersion": "131.0.6778.0" }, { "name": "firefox", From d40425ea589c7644ffdefa85427b3c726151ef9a Mon Sep 17 00:00:00 2001 From: Anand M Cherian <63868951+Anand-M-Cherian@users.noreply.github.com> Date: Wed, 16 Oct 2024 01:15:03 +0530 Subject: [PATCH 24/29] docs: update to "Matching one of the two alternative locators" section (#33079) Signed-off-by: Anand M Cherian <63868951+Anand-M-Cherian@users.noreply.github.com> Co-authored-by: Dmitry Gozman --- docs/src/locators.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/locators.md b/docs/src/locators.md index 0aa918e53c..648a654177 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -1218,7 +1218,7 @@ var button = page.GetByRole(AriaRole.Button).And(page.GetByTitle("Subscribe")); ### Matching one of the two alternative locators -If you'd like to target one of the two or more elements, and you don't know which one it will be, use [`method: Locator.or`] to create a locator that matches all of the alternatives. +If you'd like to target one of the two or more elements, and you don't know which one it will be, use [`method: Locator.or`] to create a locator that matches any one or both of the alternatives. For example, consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly. From 23b1012c704ad7d4c479be1bc5144d1ce6986fad Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 15 Oct 2024 13:34:08 -0700 Subject: [PATCH 25/29] chore: fix ff test for codegen (#33122) --- tests/library/inspector/pause.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/library/inspector/pause.spec.ts b/tests/library/inspector/pause.spec.ts index 405fffbe5b..12b7ed940f 100644 --- a/tests/library/inspector/pause.spec.ts +++ b/tests/library/inspector/pause.spec.ts @@ -483,6 +483,7 @@ it.describe('pause', () => { }); it('should record from debugger', async ({ page, recorderPageGetter }) => { + await page.setContent(''); const scriptPromise = (async () => { await page.pause(); })(); From 4b1fbde2adbfc3d9c9160d2b46671eb0c47c506e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 15 Oct 2024 13:38:55 -0700 Subject: [PATCH 26/29] chore: generate match snapshot (#33105) --- .../src/server/codegen/csharp.ts | 2 + .../src/server/codegen/java.ts | 2 + .../src/server/codegen/javascript.ts | 19 +++++- .../src/server/codegen/python.ts | 2 + .../src/server/injected/ariaSnapshot.ts | 25 ++++---- .../src/server/injected/highlight.css | 5 ++ .../src/server/injected/injectedScript.ts | 6 +- .../src/server/injected/recorder/clipPaths.ts | 2 +- .../server/injected/recorder/icons/gist.svg | 1 + .../src/server/injected/recorder/recorder.ts | 59 ++++++++++++++++--- .../playwright-core/src/server/recorder.ts | 2 +- .../src/utils/isomorphic/recorderUtils.ts | 9 +++ packages/recorder/src/actions.ts | 12 +++- packages/recorder/src/recorder.tsx | 1 + packages/recorder/src/recorderTypes.ts | 3 +- tests/page/to-match-aria-snapshot.spec.ts | 15 +++-- utils/generate_clip_paths.js | 1 + 17 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 packages/playwright-core/src/server/injected/recorder/icons/gist.svg diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index 8e6561f04e..2e4526d0a2 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -146,6 +146,8 @@ export class CSharpLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `ToHaveValueAsync(${quote(action.value)})` : `ToBeEmptyAsync()`; return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertSnapshot': + return `await Expect(${subject}.${this._asLocator(action.selector)}).ToMatchAriaSnapshotAsync(${quote(action.snapshot)});`; } } diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 5b417c6c3a..c6d41e607b 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -133,6 +133,8 @@ export class JavaLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `hasValue(${quote(action.value)})` : `isEmpty()`; return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`; } + case 'assertSnapshot': + return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).matchesAriaSnapshot(${quote(action.snapshot)});`; } } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index b68a8104a8..c0e62d9df3 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -117,6 +117,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `toHaveValue(${quote(action.value)})` : `toBeEmpty()`; return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertSnapshot': + return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).toMatchAriaSnapshot(${quoteMultiline(action.snapshot)});`; } } @@ -228,11 +230,13 @@ export class JavaScriptFormatter { } prepend(text: string) { - this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines); + const trim = isMultilineString(text) ? (line: string) => line : (line: string) => line.trim(); + this._lines = text.trim().split('\n').map(trim).concat(this._lines); } add(text: string) { - this._lines.push(...text.trim().split('\n').map(line => line.trim())); + const trim = isMultilineString(text) ? (line: string) => line : (line: string) => line.trim(); + this._lines.push(...text.trim().split('\n').map(trim)); } newLine() { @@ -269,3 +273,14 @@ function wrapWithStep(description: string | undefined, body: string) { ${body} });` : body; } + +export function quoteMultiline(text: string, indent = ' ') { + const lines = text.split('\n'); + if (lines.length === 1) + return '`' + text.replace(/`/g, '\\`').replace(/\${/g, '\\${') + '`'; + return '`\n' + lines.map(line => indent + line.replace(/`/g, '\\`').replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``; +} + +function isMultilineString(text: string) { + return text.match(/`[\S\s]*`/)?.[0].includes('\n'); +} diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 38894695bc..50afe1b1a5 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -126,6 +126,8 @@ export class PythonLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `to_have_value(${quote(action.value)})` : `to_be_empty()`; return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertSnapshot': + return `expect(${subject}.${this._asLocator(action.selector)}).to_match_aria_snapshot(${quote(action.snapshot)})`; } } diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 8e08ae7016..22ec9b5c42 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -90,8 +90,7 @@ export function generateAriaTree(rootElement: Element): AriaNode { } beginAriaCaches(); - const result = toAriaNode(rootElement); - const ariaRoot = result?.ariaNode || { role: '' }; + const ariaRoot: AriaNode = { role: '' }; try { visit(ariaRoot, rootElement); } finally { @@ -218,7 +217,11 @@ function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean { export function renderAriaTree(ariaNode: AriaNode): string { const lines: string[] = []; - const visit = (ariaNode: AriaNode, indent: string) => { + const visit = (ariaNode: AriaNode | string, indent: string) => { + if (typeof ariaNode === 'string') { + lines.push(indent + '- text: ' + escapeYamlString(ariaNode)); + return; + } let line = `${indent}- ${ariaNode.role}`; if (ariaNode.name) line += ` ${escapeWithQuotes(ariaNode.name, '"')}`; @@ -231,14 +234,16 @@ export function renderAriaTree(ariaNode: AriaNode): string { return; } lines.push(line + (ariaNode.children ? ':' : '')); - for (const child of ariaNode.children || []) { - if (typeof child === 'string') - lines.push(indent + ' - text: ' + escapeYamlString(child)); - else - visit(child, indent + ' '); - } + for (const child of ariaNode.children || []) + visit(child, indent + ' '); }; - visit(ariaNode, ''); + if (ariaNode.role === '') { + // Render fragment. + for (const child of ariaNode.children || []) + visit(child, ''); + } else { + visit(ariaNode, ''); + } return lines.join('\n'); } diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 83123011bc..096f931161 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -220,6 +220,11 @@ x-pw-tool-item.value > x-div { clip-path: url(#icon-symbol-constant); } +x-pw-tool-item.snapshot > x-div { + /* codicon: eye */ + clip-path: url(#icon-gist); +} + x-pw-tool-item.accept > x-div { clip-path: url(#icon-check); } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 0f3308fe7c..66a18848db 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -34,7 +34,7 @@ import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } fr import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; -import { matchesAriaTree } from './ariaSnapshot'; +import { matchesAriaTree, renderedAriaTree } from './ariaSnapshot'; export type FrameExpectParams = Omit & { expectedValue?: any }; @@ -206,6 +206,10 @@ export class InjectedScript { return new Set(result.map(r => r.element)); } + renderedAriaTree(target: Element): string { + return renderedAriaTree(target); + } + querySelectorAll(selector: ParsedSelector, root: Node): Element[] { if (selector.capture !== undefined) { if (selector.parts.some(part => part.name === 'nth')) diff --git a/packages/playwright-core/src/server/injected/recorder/clipPaths.ts b/packages/playwright-core/src/server/injected/recorder/clipPaths.ts index faa77a63d6..a1e016542a 100644 --- a/packages/playwright-core/src/server/injected/recorder/clipPaths.ts +++ b/packages/playwright-core/src/server/injected/recorder/clipPaths.ts @@ -27,5 +27,5 @@ import type { SvgJson } from './recorder'; // eslint-disable-next-line key-spacing, object-curly-spacing, comma-spacing, quotes -const svgJson: SvgJson = {"tagName":"svg","children":[{"tagName":"defs","children":[{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gripper"},"children":[{"tagName":"path","attrs":{"d":"M5 3h2v2H5zm0 4h2v2H5zm0 4h2v2H5zm4-8h2v2H9zm0 4h2v2H9zm0 4h2v2H9z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-circle-large-filled"},"children":[{"tagName":"path","attrs":{"d":"M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-inspect"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-whole-word"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M0 11H1V13H15V11H16V14H15H1H0V11Z"}},{"tagName":"path","attrs":{"d":"M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"}},{"tagName":"path","attrs":{"d":"M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-eye"},"children":[{"tagName":"path","attrs":{"d":"M7.99993 6.00316C9.47266 6.00316 10.6666 7.19708 10.6666 8.66981C10.6666 10.1426 9.47266 11.3365 7.99993 11.3365C6.52715 11.3365 5.33324 10.1426 5.33324 8.66981C5.33324 7.19708 6.52715 6.00316 7.99993 6.00316ZM7.99993 7.00315C7.07946 7.00315 6.33324 7.74935 6.33324 8.66981C6.33324 9.59028 7.07946 10.3365 7.99993 10.3365C8.9204 10.3365 9.6666 9.59028 9.6666 8.66981C9.6666 7.74935 8.9204 7.00315 7.99993 7.00315ZM7.99993 3.66675C11.0756 3.66675 13.7307 5.76675 14.4673 8.70968C14.5344 8.97755 14.3716 9.24908 14.1037 9.31615C13.8358 9.38315 13.5643 9.22041 13.4973 8.95248C12.8713 6.45205 10.6141 4.66675 7.99993 4.66675C5.38454 4.66675 3.12664 6.45359 2.50182 8.95555C2.43491 9.22341 2.16348 9.38635 1.89557 9.31948C1.62766 9.25255 1.46471 8.98115 1.53162 8.71321C2.26701 5.76856 4.9229 3.66675 7.99993 3.66675Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-symbol-constant"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M4 6h8v1H4V6zm8 3H4v1h8V9z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-check"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-close"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-pass"},"children":[{"tagName":"path","attrs":{"d":"M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z"}}]}]}]}; +const svgJson: SvgJson = {"tagName":"svg","children":[{"tagName":"defs","children":[{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gripper"},"children":[{"tagName":"path","attrs":{"d":"M5 3h2v2H5zm0 4h2v2H5zm0 4h2v2H5zm4-8h2v2H9zm0 4h2v2H9zm0 4h2v2H9z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-circle-large-filled"},"children":[{"tagName":"path","attrs":{"d":"M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-inspect"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-whole-word"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M0 11H1V13H15V11H16V14H15H1H0V11Z"}},{"tagName":"path","attrs":{"d":"M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"}},{"tagName":"path","attrs":{"d":"M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-eye"},"children":[{"tagName":"path","attrs":{"d":"M7.99993 6.00316C9.47266 6.00316 10.6666 7.19708 10.6666 8.66981C10.6666 10.1426 9.47266 11.3365 7.99993 11.3365C6.52715 11.3365 5.33324 10.1426 5.33324 8.66981C5.33324 7.19708 6.52715 6.00316 7.99993 6.00316ZM7.99993 7.00315C7.07946 7.00315 6.33324 7.74935 6.33324 8.66981C6.33324 9.59028 7.07946 10.3365 7.99993 10.3365C8.9204 10.3365 9.6666 9.59028 9.6666 8.66981C9.6666 7.74935 8.9204 7.00315 7.99993 7.00315ZM7.99993 3.66675C11.0756 3.66675 13.7307 5.76675 14.4673 8.70968C14.5344 8.97755 14.3716 9.24908 14.1037 9.31615C13.8358 9.38315 13.5643 9.22041 13.4973 8.95248C12.8713 6.45205 10.6141 4.66675 7.99993 4.66675C5.38454 4.66675 3.12664 6.45359 2.50182 8.95555C2.43491 9.22341 2.16348 9.38635 1.89557 9.31948C1.62766 9.25255 1.46471 8.98115 1.53162 8.71321C2.26701 5.76856 4.9229 3.66675 7.99993 3.66675Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-symbol-constant"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M4 6h8v1H4V6zm8 3H4v1h8V9z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-check"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-close"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-pass"},"children":[{"tagName":"path","attrs":{"d":"M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gist"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M10.57 1.14l3.28 3.3.15.36v9.7l-.5.5h-11l-.5-.5v-13l.5-.5h7.72l.35.14zM10 5h3l-3-3v3zM3 2v12h10V6H9.5L9 5.5V2H3zm2.062 7.533l1.817-1.828L6.17 7 4 9.179v.707l2.171 2.174.707-.707-1.816-1.82zM8.8 7.714l.7-.709 2.189 2.175v.709L9.5 12.062l-.705-.709 1.831-1.82L8.8 7.714z"}}]}]}]}; export default svgJson; \ No newline at end of file diff --git a/packages/playwright-core/src/server/injected/recorder/icons/gist.svg b/packages/playwright-core/src/server/injected/recorder/icons/gist.svg new file mode 100644 index 0000000000..f6d50e43d4 --- /dev/null +++ b/packages/playwright-core/src/server/injected/recorder/icons/gist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index cdc29a1050..d620ca273c 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -608,9 +608,9 @@ class TextAssertionTool implements RecorderTool { private _action: actions.AssertAction | null = null; private _dialog: Dialog; private _textCache = new Map(); - private _kind: 'text' | 'value'; + private _kind: 'text' | 'value' | 'snapshot'; - constructor(recorder: Recorder, kind: 'text' | 'value') { + constructor(recorder: Recorder, kind: 'text' | 'value' | 'snapshot') { this._recorder = recorder; this._kind = kind; this._dialog = new Dialog(recorder); @@ -656,7 +656,7 @@ class TextAssertionTool implements RecorderTool { const target = this._recorder.deepEventTarget(event); if (this._hoverHighlight?.elements[0] === target) return; - if (this._kind === 'text') + if (this._kind === 'text' || this._kind === 'snapshot') this._hoverHighlight = this._recorder.injectedScript.utils.elementText(this._textCache, target).full ? { elements: [target], selector: '' } : null; else this._hoverHighlight = this._elementHasValue(target) ? this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null; @@ -704,6 +704,18 @@ class TextAssertionTool implements RecorderTool { value: (target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).value, }; } + } if (this._kind === 'snapshot') { + this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); + this._hoverHighlight.color = '#8acae480'; + // forTextExpect can update the target, re-highlight it. + this._recorder.updateHighlight(this._hoverHighlight, true); + + return { + name: 'assertSnapshot', + selector: this._hoverHighlight.selector, + signals: [], + snapshot: this._recorder.injectedScript.renderedAriaTree(target), + }; } else { this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); this._hoverHighlight.color = '#8acae480'; @@ -727,6 +739,8 @@ class TextAssertionTool implements RecorderTool { return String(action.checked); if (action?.name === 'assertValue') return action.value; + if (action?.name === 'assertSnapshot') + return action.snapshot; return ''; } @@ -742,13 +756,19 @@ class TextAssertionTool implements RecorderTool { if (!this._hoverHighlight?.elements[0]) return; this._action = this._generateAction(); - if (!this._action || this._action.name !== 'assertText') - return; + if (this._action?.name === 'assertText') { + this._showTextDialog(this._action); + } else if (this._action?.name === 'assertSnapshot') { + this._recorder.recordAction(this._action); + this._recorder.setMode('recording'); + this._recorder.overlay?.flashToolSucceeded('assertingSnapshot'); + } + } - const action = this._action; + private _showTextDialog(action: actions.AssertTextAction) { const textElement = this._recorder.document.createElement('textarea'); textElement.setAttribute('spellcheck', 'false'); - textElement.value = this._renderValue(this._action); + textElement.value = this._renderValue(action); textElement.classList.add('text-editor'); const updateAndValidate = () => { @@ -796,6 +816,7 @@ class Overlay { private _assertVisibilityToggle: HTMLElement; private _assertTextToggle: HTMLElement; private _assertValuesToggle: HTMLElement; + private _assertSnapshotToggle: HTMLElement; private _offsetX = 0; private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined; private _measure: { width: number, height: number } = { width: 0, height: 0 }; @@ -842,6 +863,12 @@ class Overlay { this._assertValuesToggle.appendChild(this._recorder.document.createElement('x-div')); toolsListElement.appendChild(this._assertValuesToggle); + this._assertSnapshotToggle = this._recorder.document.createElement('x-pw-tool-item'); + this._assertSnapshotToggle.title = 'Assert snapshot'; + this._assertSnapshotToggle.classList.add('snapshot'); + this._assertSnapshotToggle.appendChild(this._recorder.document.createElement('x-div')); + toolsListElement.appendChild(this._assertSnapshotToggle); + this._updateVisualPosition(); this._refreshListeners(); } @@ -865,6 +892,7 @@ class Overlay { 'assertingText': 'recording-inspecting', 'assertingVisibility': 'recording-inspecting', 'assertingValue': 'recording-inspecting', + 'assertingSnapshot': 'recording-inspecting', }; this._recorder.setMode(newMode[this._recorder.state.mode]); }), @@ -880,6 +908,10 @@ class Overlay { if (!this._assertValuesToggle.classList.contains('disabled')) this._recorder.setMode(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue'); }), + addEventListener(this._assertSnapshotToggle, 'click', () => { + if (!this._assertSnapshotToggle.classList.contains('disabled')) + this._recorder.setMode(this._recorder.state.mode === 'assertingSnapshot' ? 'recording' : 'assertingSnapshot'); + }), ]; } @@ -902,6 +934,8 @@ class Overlay { this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue'); this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); + this._assertSnapshotToggle.classList.toggle('active', state.mode === 'assertingSnapshot'); + this._assertSnapshotToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; this._updateVisualPosition(); @@ -912,8 +946,14 @@ class Overlay { this._showOverlay(); } - flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') { - const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle; + flashToolSucceeded(tool: 'assertingVisibility' | 'assertingSnapshot' | 'assertingValue') { + let element: Element; + if (tool === 'assertingVisibility') + element = this._assertVisibilityToggle; + else if (tool === 'assertingSnapshot') + element = this._assertSnapshotToggle; + else + element = this._assertValuesToggle; element.classList.add('succeeded'); this._recorder.injectedScript.builtinSetTimeout(() => element.classList.remove('succeeded'), 2000); } @@ -1004,6 +1044,7 @@ export class Recorder { 'assertingText': new TextAssertionTool(this, 'text'), 'assertingVisibility': new InspectTool(this, true), 'assertingValue': new TextAssertionTool(this, 'value'), + 'assertingSnapshot': new TextAssertionTool(this, 'snapshot'), }; this._currentTool = this._tools.none; if (injectedScript.window.top === injectedScript.window) { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 386e4dece6..c5e133c069 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -216,7 +216,7 @@ export class Recorder implements InstrumentationListener, IRecorder { this._highlightedSelector = ''; this._mode = mode; this._recorderApp?.setMode(this._mode); - this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); + this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue' || this._mode === 'assertingSnapshot'); this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); if (this._mode !== 'none' && this._mode !== 'standby' && this._context.pages().length === 1) this._context.pages()[0].bringToFront().catch(() => {}); diff --git a/packages/playwright-core/src/utils/isomorphic/recorderUtils.ts b/packages/playwright-core/src/utils/isomorphic/recorderUtils.ts index 40ce3acd44..349afe95a5 100644 --- a/packages/playwright-core/src/utils/isomorphic/recorderUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/recorderUtils.ts @@ -130,6 +130,15 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo }; return { method: 'expect', params }; } + case 'assertSnapshot': { + const params: channels.FrameExpectParams = { + selector, + expression: 'to.match.snapshot', + expectedText: [], + isNot: false, + }; + return { method: 'expect', params }; + } } } diff --git a/packages/recorder/src/actions.ts b/packages/recorder/src/actions.ts index a17e0c172b..d4c74b2656 100644 --- a/packages/recorder/src/actions.ts +++ b/packages/recorder/src/actions.ts @@ -30,7 +30,8 @@ export type ActionName = 'assertText' | 'assertValue' | 'assertChecked' | - 'assertVisible'; + 'assertVisible' | + 'assertSnapshot'; export type ActionBase = { name: ActionName, @@ -113,8 +114,13 @@ export type AssertVisibleAction = ActionWithSelector & { name: 'assertVisible', }; -export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction; -export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction; +export type AssertSnapshotAction = ActionWithSelector & { + name: 'assertSnapshot', + snapshot: string, +}; + +export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction; +export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction; export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction; // Signals. diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 19b7bc12a7..a8cd2b2719 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -116,6 +116,7 @@ export const Recorder: React.FC = ({ 'assertingText': 'recording-inspecting', 'assertingVisibility': 'recording-inspecting', 'assertingValue': 'recording-inspecting', + 'assertingSnapshot': 'recording-inspecting', }[mode]; window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { }); }}>
diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index dd379f7ccd..a0e04a7283 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -26,7 +26,8 @@ export type Mode = | 'recording-inspecting' | 'standby' | 'assertingVisibility' - | 'assertingValue'; + | 'assertingValue' + | 'assertingSnapshot'; export type EventData = { event: diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 826f8cc90e..5e58ba94e0 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -78,7 +78,7 @@ test('should match complex', async ({ page }) => { test('should match regex', async ({ page }) => { await page.setContent(`

Issues 12

`); await expect(page.locator('body')).toMatchAriaSnapshot(` - - heading /Issues \\d+/ + - heading ${/Issues \d+/} `); }); @@ -178,14 +178,17 @@ test('expected formatter', async ({ page }) => { - heading "todos" - textbox "Wrong text" `, { timeout: 1 }).catch(e => e); - expect(stripAnsi(error.message)).toContain(`- Expected - 3 + + expect(stripAnsi(error.message)).toContain(` +Locator: locator('body') +- Expected - 4 + Received string + 3 - -+ - : -+ - banner: - - heading "todos" ++ - banner: +- - heading "todos" ++ - heading "todos" - - textbox "Wrong text" - -+ - textbox "What needs to be done?"`); ++ - textbox "What needs to be done?"`); }); diff --git a/utils/generate_clip_paths.js b/utils/generate_clip_paths.js index cbef6ece9d..83d26a905c 100644 --- a/utils/generate_clip_paths.js +++ b/utils/generate_clip_paths.js @@ -64,6 +64,7 @@ const iconNames = [ 'check', 'close', 'pass', + 'gist', ]; (async () => { From b421bd8b0da0477c13684597e18c811dcfc71519 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 15 Oct 2024 15:21:45 -0700 Subject: [PATCH 27/29] chore: add a basic snapshot generator test (#33123) --- docs/src/api/class-locatorassertions.md | 39 ++++++++-- .../playwright-core/ThirdPartyNotices.txt | 21 +++++- .../bundles/utils/package-lock.json | 19 ++++- .../bundles/utils/package.json | 1 + .../bundles/utils/src/utilsBundleImpl.ts | 3 + .../src/server/ariaSnapshot.ts | 74 +++++++++++++++++++ .../src/server/dispatchers/frameDispatcher.ts | 5 +- .../src/server/injected/recorder/recorder.ts | 2 +- packages/playwright-core/src/utilsBundle.ts | 1 + packages/playwright/ThirdPartyNotices.txt | 21 +----- .../bundles/utils/package-lock.json | 19 +---- .../playwright/bundles/utils/package.json | 3 +- .../bundles/utils/src/utilsBundleImpl.ts | 3 - .../src/matchers/toMatchAriaSnapshot.ts | 61 +-------------- packages/playwright/src/utilsBundle.ts | 1 - packages/playwright/types/test.d.ts | 2 - .../inspector/cli-codegen-aria.spec.ts | 42 +++++++++++ tests/library/inspector/inspectorTest.ts | 9 +++ 18 files changed, 211 insertions(+), 115 deletions(-) create mode 100644 packages/playwright-core/src/server/ariaSnapshot.ts create mode 100644 tests/library/inspector/cli-codegen-aria.spec.ts diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 46f8f610e0..b48b3fce7e 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -2106,15 +2106,14 @@ Expected options currently selected. ## async method: LocatorAssertions.toMatchAriaSnapshot * since: v1.49 -* langs: js +* langs: + - alias-java: matchesAriaSnapshot Asserts that the target element matches the given accessibility snapshot. **Usage** ```js -import { role as x } from '@playwright/test'; -// ... await page.goto('https://demo.playwright.dev/todomvc/'); await expect(page.locator('body')).toMatchAriaSnapshot(` - heading "todos" @@ -2122,11 +2121,41 @@ await expect(page.locator('body')).toMatchAriaSnapshot(` `); ``` +```python async +await page.goto('https://demo.playwright.dev/todomvc/') +await expect(page.locator('body')).to_match_aria_snapshot(''' + - heading "todos" + - textbox "What needs to be done?" +''') +``` + +```python sync +page.goto('https://demo.playwright.dev/todomvc/') +expect(page.locator('body')).to_match_aria_snapshot(''' + - heading "todos" + - textbox "What needs to be done?" +''') +``` + +```csharp +await page.GotoAsync("https://demo.playwright.dev/todomvc/"); +await Expect(page.Locator("body")).ToMatchAriaSnapshotAsync(@" + - heading ""todos"" + - textbox ""What needs to be done?"" +"); +``` + +```java +page.navigate("https://demo.playwright.dev/todomvc/"); +assertThat(page.locator("body")).matchesAriaSnapshot(""" + - heading "todos" + - textbox "What needs to be done?" +"""); +``` + ### param: LocatorAssertions.toMatchAriaSnapshot.expected * since: v1.49 -* langs: js - `expected` ### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%% * since: v1.49 -* langs: js diff --git a/packages/playwright-core/ThirdPartyNotices.txt b/packages/playwright-core/ThirdPartyNotices.txt index a5d4ca7d0b..23e3cff257 100644 --- a/packages/playwright-core/ThirdPartyNotices.txt +++ b/packages/playwright-core/ThirdPartyNotices.txt @@ -48,6 +48,7 @@ This project incorporates components from the projects listed below. The origina - stack-utils@2.0.5 (https://github.com/tapjs/stack-utils) - wrappy@1.0.2 (https://github.com/npm/wrappy) - ws@8.17.1 (https://github.com/websockets/ws) +- yaml@2.6.0 (https://github.com/eemeli/yaml) - yauzl@2.10.0 (https://github.com/thejoshwolfe/yauzl) - yazl@2.5.1 (https://github.com/thejoshwolfe/yazl) @@ -1121,6 +1122,24 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= END OF ws@8.17.1 AND INFORMATION +%% yaml@2.6.0 NOTICES AND INFORMATION BEGIN HERE +========================================= +Copyright Eemeli Aro + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +========================================= +END OF yaml@2.6.0 AND INFORMATION + %% yauzl@2.10.0 NOTICES AND INFORMATION BEGIN HERE ========================================= The MIT License (MIT) @@ -1175,6 +1194,6 @@ END OF yazl@2.5.1 AND INFORMATION SUMMARY BEGIN HERE ========================================= -Total Packages: 46 +Total Packages: 47 ========================================= END OF SUMMARY \ No newline at end of file diff --git a/packages/playwright-core/bundles/utils/package-lock.json b/packages/playwright-core/bundles/utils/package-lock.json index 0e5e761433..f786cb4db7 100644 --- a/packages/playwright-core/bundles/utils/package-lock.json +++ b/packages/playwright-core/bundles/utils/package-lock.json @@ -25,7 +25,8 @@ "signal-exit": "3.0.7", "socks-proxy-agent": "8.0.4", "stack-utils": "2.0.5", - "ws": "8.17.1" + "ws": "8.17.1", + "yaml": "^2.5.1" }, "devDependencies": { "@types/debug": "^4.1.7", @@ -432,6 +433,17 @@ "optional": true } } + }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } } }, "dependencies": { @@ -726,6 +738,11 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "requires": {} + }, + "yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==" } } } diff --git a/packages/playwright-core/bundles/utils/package.json b/packages/playwright-core/bundles/utils/package.json index 06637adabe..005e32fbe6 100644 --- a/packages/playwright-core/bundles/utils/package.json +++ b/packages/playwright-core/bundles/utils/package.json @@ -26,6 +26,7 @@ "signal-exit": "3.0.7", "socks-proxy-agent": "8.0.4", "stack-utils": "2.0.5", + "yaml": "^2.5.1", "ws": "8.17.1" }, "devDependencies": { diff --git a/packages/playwright-core/bundles/utils/src/utilsBundleImpl.ts b/packages/playwright-core/bundles/utils/src/utilsBundleImpl.ts index dcb3790629..975e291bb5 100644 --- a/packages/playwright-core/bundles/utils/src/utilsBundleImpl.ts +++ b/packages/playwright-core/bundles/utils/src/utilsBundleImpl.ts @@ -54,6 +54,9 @@ export { SocksProxyAgent } from 'socks-proxy-agent'; import StackUtilsLibrary from 'stack-utils'; export const StackUtils = StackUtilsLibrary; +import yamlLibrary from 'yaml'; +export const yaml = yamlLibrary; + // @ts-ignore import wsLibrary, { WebSocketServer, Receiver, Sender } from 'ws'; export const ws = wsLibrary; diff --git a/packages/playwright-core/src/server/ariaSnapshot.ts b/packages/playwright-core/src/server/ariaSnapshot.ts new file mode 100644 index 0000000000..6f89dd21cf --- /dev/null +++ b/packages/playwright-core/src/server/ariaSnapshot.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { AriaTemplateNode } from './injected/ariaSnapshot'; +import { yaml } from '../utilsBundle'; + +export function parseAriaSnapshot(text: string): AriaTemplateNode { + type YamlNode = Record | string>; + + const parseKey = (key: string): AriaTemplateNode => { + if (!key) + return { role: '' }; + + const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/); + + if (!match) + throw new Error(`Invalid key ${key}`); + + const role = match[1]; + if (role && role !== 'text' && !allRoles.includes(role)) + throw new Error(`Invalid role ${role}`); + + if (match[2]) + return { role, name: match[2] }; + if (match[3]) + return { role, name: new RegExp(match[3]) }; + return { role }; + }; + + const valueOrRegex = (value: string): string | RegExp => { + return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : value; + }; + + const convert = (object: YamlNode | string): AriaTemplateNode | RegExp | string => { + const key = typeof object === 'string' ? object : Object.keys(object)[0]; + const value = typeof object === 'string' ? undefined : object[key]; + const parsed = parseKey(key); + if (parsed.role === 'text') { + if (typeof value !== 'string') + throw new Error(`Generic role must have a text value`); + return valueOrRegex(value as string); + } + if (Array.isArray(value)) + parsed.children = value.map(convert); + else if (value) + parsed.children = [valueOrRegex(value)]; + return parsed; + }; + const fragment = yaml.parse(text) as YamlNode[]; + return convert({ '': fragment }) as AriaTemplateNode; +} + +const allRoles = [ + 'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'command', + 'complementary', 'composite', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid', + 'gridcell', 'group', 'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'meter', 'menu', + 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup', + 'range', 'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'section', 'sectionhead', 'select', 'separator', 'slider', + 'spinbutton', 'status', 'strong', 'structure', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', + 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'widget', 'window' +]; diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 6dcb9a7220..d058085cb7 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -26,6 +26,7 @@ import type { CallMetadata } from '../instrumentation'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { PageDispatcher } from './pageDispatcher'; import { debugAssert } from '../../utils'; +import { parseAriaSnapshot } from '../ariaSnapshot'; export class FrameDispatcher extends Dispatcher implements channels.FrameChannel { _type_Frame = true; @@ -258,7 +259,9 @@ export class FrameDispatcher extends Dispatcher { metadata.potentiallyClosesScope = true; - const expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined; + let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined; + if (params.expression === 'to.match.aria' && expectedValue) + expectedValue = parseAriaSnapshot(expectedValue); const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue }); if (result.received !== undefined) result.received = serializeResult(result.received); diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index d620ca273c..fd2cdcdb7c 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -704,7 +704,7 @@ class TextAssertionTool implements RecorderTool { value: (target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).value, }; } - } if (this._kind === 'snapshot') { + } else if (this._kind === 'snapshot') { this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); this._hoverHighlight.color = '#8acae480'; // forTextExpect can update the target, re-highlight it. diff --git a/packages/playwright-core/src/utilsBundle.ts b/packages/playwright-core/src/utilsBundle.ts index a2a62be867..b330491e0c 100644 --- a/packages/playwright-core/src/utilsBundle.ts +++ b/packages/playwright-core/src/utilsBundle.ts @@ -31,6 +31,7 @@ export const PNG: typeof import('../bundles/utils/node_modules/@types/pngjs').PN export const program: typeof import('../bundles/utils/node_modules/commander').program = require('./utilsBundleImpl').program; export const progress: typeof import('../bundles/utils/node_modules/@types/progress') = require('./utilsBundleImpl').progress; export const SocksProxyAgent: typeof import('../bundles/utils/node_modules/socks-proxy-agent').SocksProxyAgent = require('./utilsBundleImpl').SocksProxyAgent; +export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml; export const ws: typeof import('../bundles/utils/node_modules/@types/ws') = require('./utilsBundleImpl').ws; export const wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer; export const wsReceiver = require('./utilsBundleImpl').wsReceiver; diff --git a/packages/playwright/ThirdPartyNotices.txt b/packages/playwright/ThirdPartyNotices.txt index 46c2f60cd2..f2bb64d661 100644 --- a/packages/playwright/ThirdPartyNotices.txt +++ b/packages/playwright/ThirdPartyNotices.txt @@ -155,7 +155,6 @@ This project incorporates components from the projects listed below. The origina - undici-types@6.19.8 (https://github.com/nodejs/undici) - update-browserslist-db@1.0.13 (https://github.com/browserslist/update-db) - yallist@3.1.1 (https://github.com/isaacs/yallist) -- yaml@2.5.1 (https://github.com/eemeli/yaml) %% @ampproject/remapping@2.2.1 NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -4398,26 +4397,8 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ========================================= END OF yallist@3.1.1 AND INFORMATION -%% yaml@2.5.1 NOTICES AND INFORMATION BEGIN HERE -========================================= -Copyright Eemeli Aro - -Permission to use, copy, modify, and/or distribute this software for any purpose -with or without fee is hereby granted, provided that the above copyright notice -and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS -OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER -TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF -THIS SOFTWARE. -========================================= -END OF yaml@2.5.1 AND INFORMATION - SUMMARY BEGIN HERE ========================================= -Total Packages: 152 +Total Packages: 151 ========================================= END OF SUMMARY \ No newline at end of file diff --git a/packages/playwright/bundles/utils/package-lock.json b/packages/playwright/bundles/utils/package-lock.json index 90df9a258b..fcf9f972fe 100644 --- a/packages/playwright/bundles/utils/package-lock.json +++ b/packages/playwright/bundles/utils/package-lock.json @@ -13,8 +13,7 @@ "json5": "2.2.3", "pirates": "4.0.4", "source-map-support": "0.5.21", - "stoppable": "1.1.0", - "yaml": "^2.5.1" + "stoppable": "1.1.0" }, "devDependencies": { "@types/source-map-support": "^0.5.4", @@ -281,17 +280,6 @@ "engines": { "node": ">=8.0" } - }, - "node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } } }, "dependencies": { @@ -476,11 +464,6 @@ "requires": { "is-number": "^7.0.0" } - }, - "yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==" } } } diff --git a/packages/playwright/bundles/utils/package.json b/packages/playwright/bundles/utils/package.json index dc807c0d95..69477909c5 100644 --- a/packages/playwright/bundles/utils/package.json +++ b/packages/playwright/bundles/utils/package.json @@ -14,8 +14,7 @@ "json5": "2.2.3", "pirates": "4.0.4", "source-map-support": "0.5.21", - "stoppable": "1.1.0", - "yaml": "^2.5.1" + "stoppable": "1.1.0" }, "devDependencies": { "@types/source-map-support": "^0.5.4", diff --git a/packages/playwright/bundles/utils/src/utilsBundleImpl.ts b/packages/playwright/bundles/utils/src/utilsBundleImpl.ts index 76cf961ab7..7c29c301a8 100644 --- a/packages/playwright/bundles/utils/src/utilsBundleImpl.ts +++ b/packages/playwright/bundles/utils/src/utilsBundleImpl.ts @@ -31,6 +31,3 @@ export const enquirer = enquirerLibrary; import chokidarLibrary from 'chokidar'; export const chokidar = chokidarLibrary; - -import yamlLibrary from 'yaml'; -export const yaml = yamlLibrary; diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 949e44af6f..cd79ccab61 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -18,8 +18,6 @@ import type { LocatorEx } from './matchers'; import type { ExpectMatcherState } from '../../types/test'; import { kNoElementsFoundError, matcherHint, type MatcherResult } from './matcherHint'; -import type { AriaTemplateNode } from 'playwright-core/lib/server/injected/ariaSnapshot'; -import { yaml } from '../utilsBundle'; import { colors } from 'playwright-core/lib/utilsBundle'; import { EXPECTED_COLOR } from '../common/expectBundle'; import { callLogText } from '../util'; @@ -46,9 +44,8 @@ export async function toMatchAriaSnapshot( ].join('\n\n')); } - const ariaTree = toAriaTree(expected) as AriaTemplateNode; const timeout = options.timeout ?? this.timeout; - const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: ariaTree, isNot: this.isNot, timeout }); + const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout }); const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); const notFound = received === kNoElementsFoundError; @@ -76,59 +73,3 @@ export async function toMatchAriaSnapshot( timeout: timedOut ? timeout : undefined, }; } - -function parseKey(key: string): AriaTemplateNode { - if (!key) - return { role: '' }; - - const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/); - - if (!match) - throw new Error(`Invalid key ${key}`); - - const role = match[1]; - if (role && role !== 'text' && !allRoles.includes(role)) - throw new Error(`Invalid role ${role}`); - - if (match[2]) - return { role, name: match[2] }; - if (match[3]) - return { role, name: new RegExp(match[3]) }; - return { role }; -} - -function valueOrRegex(value: string): string | RegExp { - return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : value; -} - -type YamlNode = Record | string>; - -function toAriaTree(text: string): AriaTemplateNode { - const convert = (object: YamlNode | string): AriaTemplateNode | RegExp | string => { - const key = typeof object === 'string' ? object : Object.keys(object)[0]; - const value = typeof object === 'string' ? undefined : object[key]; - const parsed = parseKey(key); - if (parsed.role === 'text') { - if (typeof value !== 'string') - throw new Error(`Generic role must have a text value`); - return valueOrRegex(value as string); - } - if (Array.isArray(value)) - parsed.children = value.map(convert); - else if (value) - parsed.children = [valueOrRegex(value)]; - return parsed; - }; - const fragment = yaml.parse(text) as YamlNode[]; - return convert({ '': fragment }) as AriaTemplateNode; -} - -const allRoles = [ - 'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'command', - 'complementary', 'composite', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid', - 'gridcell', 'group', 'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'meter', 'menu', - 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup', - 'range', 'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'section', 'sectionhead', 'select', 'separator', 'slider', - 'spinbutton', 'status', 'strong', 'structure', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', - 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'widget', 'window' -]; diff --git a/packages/playwright/src/utilsBundle.ts b/packages/playwright/src/utilsBundle.ts index 5ded7993b7..072e16bb03 100644 --- a/packages/playwright/src/utilsBundle.ts +++ b/packages/playwright/src/utilsBundle.ts @@ -20,4 +20,3 @@ export const sourceMapSupport: typeof import('../bundles/utils/node_modules/@typ export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable; export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer; export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar; -export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 3f1179e34f..c96de2091a 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7644,8 +7644,6 @@ interface LocatorAssertions { * **Usage** * * ```js - * import { role as x } from '@playwright/test'; - * // ... * await page.goto('https://demo.playwright.dev/todomvc/'); * await expect(page.locator('body')).toMatchAriaSnapshot(` * - heading "todos" diff --git a/tests/library/inspector/cli-codegen-aria.spec.ts b/tests/library/inspector/cli-codegen-aria.spec.ts new file mode 100644 index 0000000000..f99f65fd6f --- /dev/null +++ b/tests/library/inspector/cli-codegen-aria.spec.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './inspectorTest'; + +test.describe(() => { + test.skip(({ mode }) => mode !== 'default'); + test.skip(({ trace, codegenMode }) => trace === 'on' && codegenMode === 'trace-events'); + + test('should generate aria snapshot', async ({ openRecorder }) => { + const { recorder } = await openRecorder(); + await recorder.setContentAndWait(`
`); + + 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"\`);`); + await expect.poll(() => + recorder.text('Python')).toContain(`expect(page.get_by_role("button")).to_match_aria_snapshot("- button \\"Submit\\"")`); + await expect.poll(() => + recorder.text('Python Async')).toContain(`await expect(page.get_by_role(\"button\")).to_match_aria_snapshot("- button \\"Submit\\"")`); + await expect.poll(() => + recorder.text('Java')).toContain(`assertThat(page.getByRole(AriaRole.BUTTON)).matchesAriaSnapshot("- button \\"Submit\\"");`); + await expect.poll(() => + recorder.text('C#')).toContain(`await Expect(page.GetByRole(AriaRole.Button)).ToMatchAriaSnapshotAsync("- button \\"Submit\\"");`); + }); +}); diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index 524fab1e57..3ed700a208 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -160,6 +160,15 @@ export class Recorder { return this._sources; } + async text(file: string): Promise { + const sources: Source[] = await this.recorderPage.evaluate(() => (window as any).playwrightSourcesEchoForTest || []); + for (const source of sources) { + if (codegenLangId2lang.get(source.id) === file) + return source.text; + } + return ''; + } + async waitForHighlight(action: () => Promise): Promise { await this.page.$$eval('x-pw-highlight', els => els.forEach(e => e.remove())); await this.page.$$eval('x-pw-tooltip', els => els.forEach(e => e.remove())); From b92b855638d1dd409883a49ef1094d52435306bd Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 15 Oct 2024 16:21:55 -0700 Subject: [PATCH 28/29] test: unflake ff debugger test (#33124) --- tests/library/inspector/inspectorTest.ts | 11 +++++++++-- tests/library/inspector/pause.spec.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index 3ed700a208..d77ad86697 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -180,6 +180,13 @@ export class Recorder { return this.page.locator('x-pw-tooltip').textContent(); } + async waitForHighlightNoTooltip(action: () => Promise): Promise { + await this.page.$$eval('x-pw-highlight', els => els.forEach(e => e.remove())); + await action(); + await this.page.locator('x-pw-highlight').waitFor(); + return ''; + } + async waitForActionPerformed(): Promise<{ hovered: string | null, active: string | null }> { let callback; const listener = async msg => { @@ -194,8 +201,8 @@ export class Recorder { return new Promise(f => callback = f); } - async hoverOverElement(selector: string, options?: { position?: { x: number, y: number }}): Promise { - return this.waitForHighlight(async () => { + async hoverOverElement(selector: string, options?: { position?: { x: number, y: number }, omitTooltip?: boolean }): Promise { + return (options?.omitTooltip ? this.waitForHighlightNoTooltip : this.waitForHighlight).call(this, async () => { const box = await this.page.locator(selector).first().boundingBox(); const offset = options?.position || { x: box.width / 2, y: box.height / 2 }; await this.page.mouse.move(box.x + offset.x, box.y + offset.y); diff --git a/tests/library/inspector/pause.spec.ts b/tests/library/inspector/pause.spec.ts index 12b7ed940f..647706956f 100644 --- a/tests/library/inspector/pause.spec.ts +++ b/tests/library/inspector/pause.spec.ts @@ -15,7 +15,7 @@ */ import type { Page } from 'playwright-core'; -import { test as it, expect } from './inspectorTest'; +import { test as it, expect, Recorder } from './inspectorTest'; import { waitForTestLog } from '../../config/utils'; @@ -491,7 +491,11 @@ it.describe('pause', () => { await expect(recorderPage.getByRole('combobox', { name: 'Source chooser' })).toHaveValue(/pause\.spec\.ts/); await expect(recorderPage.locator('.source-line-paused')).toHaveText(/await page\.pause\(\)/); await recorderPage.getByRole('button', { name: 'Record' }).click(); - await page.locator('body').click(); + + const recorder = new Recorder(page, recorderPage); + await recorder.hoverOverElement('body', { omitTooltip: true }); + await recorder.trustedClick(); + await expect(recorderPage.getByRole('combobox', { name: 'Source chooser' })).toHaveValue('javascript'); await expect(recorderPage.locator('.cm-wrapper')).toContainText(`await page.locator('body').click();`); await recorderPage.getByRole('button', { name: 'Resume' }).click(); From 94321fef1c94f9851b6fcc4304d3844760e986cb Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 15 Oct 2024 18:47:26 -0700 Subject: [PATCH 29/29] chore: implement locator.ariaSnapshot (#33125) --- docs/src/api/class-locator.md | 57 +++++++++++++++++++ .../playwright-core/src/client/locator.ts | 5 ++ .../playwright-core/src/protocol/validator.ts | 7 +++ .../src/server/dispatchers/frameDispatcher.ts | 4 ++ packages/playwright-core/src/server/dom.ts | 4 ++ packages/playwright-core/src/server/frames.ts | 7 +++ .../src/server/injected/consoleApi.ts | 3 +- .../src/server/injected/injectedScript.ts | 6 +- .../src/server/injected/recorder/recorder.ts | 2 +- packages/playwright-core/types/types.d.ts | 51 +++++++++++++++++ packages/protocol/src/channels.ts | 11 ++++ packages/protocol/src/protocol.yml | 7 +++ tests/page/page-aria-snapshot.spec.ts | 40 +++++++++++++ 13 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 tests/page/page-aria-snapshot.spec.ts diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 8f0b53e16d..ba6dc91353 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -150,6 +150,63 @@ var button = page.GetByRole(AriaRole.Button).And(page.GetByTitle("Subscribe")); Additional locator to match. +## async method: Locator.ariaSnapshot +* since: v1.49 +- returns: <[string]> + +Captures the aria snapshot of the given element. See [`method: LocatorAssertions.toMatchAriaSnapshot`] for the corresponding assertion. + +**Usage** + +```js +await page.getByRole('link').ariaSnapshot(); +``` + +```java +page.getByRole(AriaRole.LINK).ariaSnapshot(); +``` + +```python async +await page.get_by_role("link").aria_snapshot() +``` + +```python sync +page.get_by_role("link").aria_snapshot() +``` + +```csharp +await page.GetByRole(AriaRole.Link).AriaSnapshotAsync(); +``` + +**Details** + +This method captures the aria snapshot of the given element. The snapshot is a string that represents the state of the element and its children. +The snapshot can be used to assert the state of the element in the test, or to compare it to state in the future. + +The ARIA snapshot is represented using [YAML](https://yaml.org/spec/1.2.2/) markup language: +* The keys of the objects are the roles and optional accessible names of the elements. +* The values are either text content or an array of child elements. +* Generic static text can be represented with the `text` key. + +Below is the HTML markup and the respective ARIA snapshot: + +```html +
    +
  • Home
  • +
  • About
  • +
      +``` + +```yml +- list "Links": + - listitem: + - link "Home" + - listitem: + - link "About" +``` + +### option: Locator.ariaSnapshot.timeout = %%-input-timeout-js-%% +* since: v1.49 ## async method: Locator.blur * since: v1.28 diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index b6058e0abb..1b43db8de7 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -288,6 +288,11 @@ export class Locator implements api.Locator { return await this._withElement((h, timeout) => h.screenshot({ ...options, timeout }), options.timeout); } + async ariaSnapshot(options?: TimeoutOptions): Promise { + const result = await this._frame._channel.ariaSnapshot({ ...options, selector: this._selector }); + return result.snapshot; + } + async scrollIntoViewIfNeeded(options: channels.ElementHandleScrollIntoViewIfNeededOptions = {}) { return await this._withElement((h, timeout) => h.scrollIntoViewIfNeeded({ ...options, timeout }), options.timeout); } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index d5d91b165d..24ddf0014c 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1424,6 +1424,13 @@ scheme.FrameAddStyleTagParams = tObject({ scheme.FrameAddStyleTagResult = tObject({ element: tChannel(['ElementHandle']), }); +scheme.FrameAriaSnapshotParams = tObject({ + selector: tString, + timeout: tOptional(tNumber), +}); +scheme.FrameAriaSnapshotResult = tObject({ + snapshot: tString, +}); scheme.FrameBlurParams = tObject({ selector: tString, strict: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index d058085cb7..2f172df694 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -267,4 +267,8 @@ export class FrameDispatcher extends Dispatcher { + return { snapshot: await this._frame.ariaSnapshot(metadata, params.selector, params) }; + } } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 76708245d4..4bb301bffd 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -789,6 +789,10 @@ export class ElementHandle extends js.JSHandle { return this._page._delegate.getBoundingBox(this); } + async ariaSnapshot(): Promise { + return await this.evaluateInUtility(([injected, element]) => injected.ariaSnapshot(element), {}); + } + async screenshot(metadata: CallMetadata, options: ScreenshotOptions & TimeoutOptions = {}): Promise { const controller = new ProgressController(metadata, this); return controller.run( diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 6dc412c235..597ff35951 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1405,6 +1405,13 @@ export class Frame extends SdkObject { }); } + async ariaSnapshot(metadata: CallMetadata, selector: string, options: types.TimeoutOptions = {}): Promise { + const controller = new ProgressController(metadata, this); + return controller.run(async progress => { + return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => handle.ariaSnapshot()); + }, this._page._timeoutSettings.timeout(options)); + } + async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { const result = await this._expectImpl(metadata, selector, options); // Library mode special case for the expect errors which are return values, not exceptions. diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index 526694745b..0287240493 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -20,7 +20,6 @@ import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { InjectedScript } from './injectedScript'; -import { renderedAriaTree } from './ariaSnapshot'; const selectorSymbol = Symbol('selector'); @@ -86,7 +85,7 @@ class ConsoleAPI { inspect: (selector: string) => this._inspect(selector), selector: (element: Element) => this._selector(element), generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language), - ariaSnapshot: (element?: Element) => renderedAriaTree(element || this._injectedScript.document.body), + ariaSnapshot: (element?: Element) => this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body), resume: () => this._resume(), ...new Locator(injectedScript, ''), }; diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 66a18848db..7abc5850bb 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -206,8 +206,10 @@ export class InjectedScript { return new Set(result.map(r => r.element)); } - renderedAriaTree(target: Element): string { - return renderedAriaTree(target); + ariaSnapshot(node: Node): string { + if (node.nodeType !== Node.ELEMENT_NODE) + throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); + return renderedAriaTree(node as Element); } querySelectorAll(selector: ParsedSelector, root: Node): Element[] { diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index fd2cdcdb7c..c82f55c984 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -714,7 +714,7 @@ class TextAssertionTool implements RecorderTool { name: 'assertSnapshot', selector: this._hoverHighlight.selector, signals: [], - snapshot: this._recorder.injectedScript.renderedAriaTree(target), + snapshot: this._recorder.injectedScript.ariaSnapshot(target), }; } else { this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 7d1d736a13..092f75b263 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12424,6 +12424,57 @@ export interface Locator { */ and(locator: Locator): Locator; + /** + * Captures the aria snapshot of the given element. See + * [expect(locator).toMatchAriaSnapshot(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot) + * for the corresponding assertion. + * + * **Usage** + * + * ```js + * await page.getByRole('link').ariaSnapshot(); + * ``` + * + * **Details** + * + * This method captures the aria snapshot of the given element. The snapshot is a string that represents the state of + * the element and its children. The snapshot can be used to assert the state of the element in the test, or to + * compare it to state in the future. + * + * The ARIA snapshot is represented using [YAML](https://yaml.org/spec/1.2.2/) markup language: + * - The keys of the objects are the roles and optional accessible names of the elements. + * - The values are either text content or an array of child elements. + * - Generic static text can be represented with the `text` key. + * + * Below is the HTML markup and the respective ARIA snapshot: + * + * ```html + *
        + *
      • Home
      • + *
      • About
      • + *
          + * ``` + * + * ```yml + * - list "Links": + * - listitem: + * - link "Home" + * - listitem: + * - link "About" + * ``` + * + * @param options + */ + ariaSnapshot(options?: { + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + /** * Calls [blur](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/blur) on the element. * @param options diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index a0d6a30b9f..5ecc2f4077 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -2509,6 +2509,7 @@ export interface FrameChannel extends FrameEventTarget, Channel { evalOnSelectorAll(params: FrameEvalOnSelectorAllParams, metadata?: CallMetadata): Promise; addScriptTag(params: FrameAddScriptTagParams, metadata?: CallMetadata): Promise; addStyleTag(params: FrameAddStyleTagParams, metadata?: CallMetadata): Promise; + ariaSnapshot(params: FrameAriaSnapshotParams, metadata?: CallMetadata): Promise; blur(params: FrameBlurParams, metadata?: CallMetadata): Promise; check(params: FrameCheckParams, metadata?: CallMetadata): Promise; click(params: FrameClickParams, metadata?: CallMetadata): Promise; @@ -2613,6 +2614,16 @@ export type FrameAddStyleTagOptions = { export type FrameAddStyleTagResult = { element: ElementHandleChannel, }; +export type FrameAriaSnapshotParams = { + selector: string, + timeout?: number, +}; +export type FrameAriaSnapshotOptions = { + timeout?: number, +}; +export type FrameAriaSnapshotResult = { + snapshot: string, +}; export type FrameBlurParams = { selector: string, strict?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index a32ebde3d4..c91cecbe6c 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1875,6 +1875,13 @@ Frame: flags: snapshot: true + ariaSnapshot: + parameters: + selector: string + timeout: number? + returns: + snapshot: string + blur: parameters: selector: string diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts new file mode 100644 index 0000000000..766df6d485 --- /dev/null +++ b/tests/page/page-aria-snapshot.spec.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test as it, expect } from './pageTest'; + +it('should snapshot the check box @smoke', async ({ page }) => { + await page.setContent(``); + expect(await page.locator('body').ariaSnapshot()).toBe('- checkbox'); +}); + +it('should snapshot nested element', async ({ page }) => { + await page.setContent(` +
          + +
          `); + expect(await page.locator('body').ariaSnapshot()).toBe('- checkbox'); +}); + +it('should snapshot fragment', async ({ page }) => { + await page.setContent(` +
          + Link + Link +
          `); + expect(await page.locator('body').ariaSnapshot()).toBe(`- link "Link"\n- link "Link"`); +});