From 75689d189932c82a64f671325d64dfdbf645baa4 Mon Sep 17 00:00:00 2001 From: himasree424 <144548828+himasree424@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:39:25 +0530 Subject: [PATCH 1/6] feat(cli): add --add-reporter to append reporters without replacing existing config (#41993) --- docs/src/test-cli-js.md | 1 + packages/playwright/src/cli/testActions.ts | 1 + packages/playwright/src/common/config.ts | 2 +- packages/playwright/src/common/ipc.ts | 1 + packages/playwright/src/program.ts | 1 + packages/playwright/src/runner/reporters.ts | 2 -- packages/playwright/src/runner/tasks.ts | 2 -- tests/playwright-test/reporter.spec.ts | 20 ++++++++++++++++++++ 8 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/src/test-cli-js.md b/docs/src/test-cli-js.md index 85171da62fe4c..4fea276f3b685 100644 --- a/docs/src/test-cli-js.md +++ b/docs/src/test-cli-js.md @@ -79,6 +79,7 @@ npx playwright test --ui | Option | Description | | :--- | :--- | | Non-option arguments | Each argument is treated as a regular expression matched against the full test file path. Only tests from files matching the pattern will be executed. Special symbols like `$` or `*` should be escaped with `\`. In many shells/terminals you may need to quote the arguments. | +| `--add-reporter ` | Reporter to add on top of the reporters configured in the config file, comma-separated. Can be a built-in reporter name or a path to a custom reporter file. Unlike `--reporter`, this keeps the configured reporters instead of replacing them. | | `-c ` or `--config ` | Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}". Defaults to `playwright.config.ts` or `playwright.config.js` in the current directory. | | `--debug` | Run tests with Playwright Inspector. Shortcut for `PWDEBUG=1` environment variable and `--timeout=0 --max-failures=1 --headed --workers=1` options. | | `--fail-on-flaky-tests` | Fail if any test is flagged as flaky (default: false). | diff --git a/packages/playwright/src/cli/testActions.ts b/packages/playwright/src/cli/testActions.ts index c4bc09e2dfe45..f3cd08f907d63 100644 --- a/packages/playwright/src/cli/testActions.ts +++ b/packages/playwright/src/cli/testActions.ts @@ -126,6 +126,7 @@ function overridesFromOptions(options: { [key: string]: any }): ipc.ConfigCLIOve repeatEach: options.repeatEach ? parseInt(options.repeatEach, 10) : undefined, retries: options.retries ? parseInt(options.retries, 10) : undefined, reporter: resolveReporterOption(options.reporter), + additionalReporters: resolveReporterOption(options.addReporter), shard: resolveShardOption(options.shard), timeout: options.timeout ? parseInt(options.timeout, 10) : undefined, tsconfig: options.tsconfig ? path.resolve(process.cwd(), options.tsconfig) : undefined, diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 0287e87d3ee4f..99328f3f9b7eb 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -100,7 +100,7 @@ export class FullConfigInternal { preserveOutput: takeFirst(userConfig.preserveOutput, 'always'), projects: [], quiet: takeFirst(configCLIOverrides.quiet, userConfig.quiet, false), - reporter: takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]), + reporter: [...takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]), ...(configCLIOverrides.additionalReporters ?? [])], reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 300_000 /* 5 minutes */ }), shard: takeFirst(configCLIOverrides.shard, userConfig.shard, null), tags: globalTags, diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index 6cc0436d33b5f..3943730099dcf 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -36,6 +36,7 @@ export type ConfigCLIOverrides = { repeatEach?: number; retries?: number; reporter?: ReporterDescription[]; + additionalReporters?: ReporterDescription[]; shard?: { current: number, total: number }; timeout?: number; tsconfig?: string; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 2384f49a8852e..4d88f7440a4b3 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -206,6 +206,7 @@ const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries // Note: update docs/src/test-cli-js.md when you update this, program is the source of truth. const testOptions: [string, { description: string, choices?: string[], preset?: string }][] = [ + ['--add-reporter ', { description: `Reporter to add on top of the configured reporters, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} or a path to a reporter module` }], /* deprecated */ ['--browser ', { description: `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")` }], ['-c, --config ', { description: `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"` }], ['--debug [mode]', { description: `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options`, choices: ['inspector', 'cli'], preset: 'inspector' }], diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index 00dd732b63991..ef3d45a79f086 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -51,8 +51,6 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | }; const reporters: ReporterV2[] = []; descriptions ??= config.config.reporter; - if (runOptions?.additionalReporters) - descriptions = [...descriptions, ...runOptions.additionalReporters]; const reportOptions = reporterCommandOptions(config, mode, runOptions); for (const r of descriptions) { const [name, arg] = r; diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 7890ed51fc321..6699c6292cb45 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -37,7 +37,6 @@ import type { TestGroup } from '../runner/testGroups'; import type { EnvByProjectId } from './dispatcher'; import type { TestRunnerPluginRegistration } from '../plugins'; import type { Task } from './taskRunner'; -import type { ReporterDescription } from '../../types/test'; import type { FullResult, TestError } from '../../types/testReporter'; import type { Matcher, TestCaseFilter } from '../util'; import type { InternalReporter } from '../reporters/internalReporter'; @@ -72,7 +71,6 @@ export type TestRunOptions = { pauseAtEnd?: boolean; onTestPaused?: (params: TestPausedParams) => void; preserveOutputDir?: boolean; - additionalReporters?: ReporterDescription[]; shardWeights?: number[]; }; diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index e0330b1e83b4e..851148ead8e56 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -987,3 +987,23 @@ test('AggregateError sub-errors are spread into testInfo.errors', async ({ runIn expect.stringMatching(/^FRAME Error: sub b: at .*a\.spec\.ts:19:/), ]); }); + +test('--add-reporter should append to configured reporters instead of replacing them', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'configured-reporter.js': ` + module.exports = class { onBegin() { console.log('FROM_CONFIGURED_REPORTER'); } }; + `, + 'added-reporter.js': ` + module.exports = class { onBegin() { console.log('FROM_ADDED_REPORTER'); } }; + `, + 'playwright.config.ts': `module.exports = { reporter: [['./configured-reporter.js']] };`, + 'a.spec.js': ` + const { test } = require('@playwright/test'); + test('test', () => {}); + `, + }, { 'workers': 1 }, undefined, { additionalArgs: ['--add-reporter=./added-reporter.js'] }); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain('FROM_CONFIGURED_REPORTER'); + expect(result.output).toContain('FROM_ADDED_REPORTER'); +}); From f755ee9748f265288438c42efb3d232a64ca6d5c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 28 Jul 2026 16:42:06 +0100 Subject: [PATCH 2/6] =?UTF-8?q?test:=20remove=20"page/page-evaluate.spec.t?= =?UTF-8?q?s:894=20=E2=80=BA=20promise=20collected"=20(#42019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/page/page-evaluate.spec.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/page/page-evaluate.spec.ts b/tests/page/page-evaluate.spec.ts index fa45692fc6138..3877fc256ffe2 100644 --- a/tests/page/page-evaluate.spec.ts +++ b/tests/page/page-evaluate.spec.ts @@ -890,13 +890,3 @@ it('should ignore dangerous object keys', async ({ page }) => { const result = await page.evaluate(arg => arg, input); expect(result).toEqual({ safeKey: 'safeValue' }); }); - -it('promise collected', async ({ page, browserName }) => { - it.skip(browserName !== 'chromium', 'this is a chromium-only behavior'); - - const resultPromise = page.evaluate(() => new Promise(() => {})).catch(e => e); - for (let i = 0; i < 20; i++) - await page.requestGC(); - const error = await resultPromise; - expect(error.message).toContain('Resulting promise was garbage collected'); -}); From 205633dc53104128ad1abeb147a4847e0146fe00 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 28 Jul 2026 16:42:48 +0100 Subject: [PATCH 3/6] devops: do not swallow doc generation failures when rolling browsers (#42016) --- README.md | 4 +- .../isomorphic/deviceDescriptorsSource.json | 196 +++++++++--------- utils/doclint/cli.js | 5 + utils/roll_browser.js | 6 +- 4 files changed, 106 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 57210c3677139..e18fcdc1f3926 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-151.0.7922.34-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-153.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](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-151.0.7922.47-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-153.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -296,7 +296,7 @@ The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?it | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium1 151.0.7922.34 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium1 151.0.7922.47 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 26.5 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 153.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/isomorphic/deviceDescriptorsSource.json b/packages/isomorphic/deviceDescriptorsSource.json index 4abd4446388b5..75f7941a66941 100644 --- a/packages/isomorphic/deviceDescriptorsSource.json +++ b/packages/isomorphic/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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S24": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 360, "height": 780 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S24 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 780, "height": 360 @@ -198,7 +198,7 @@ "defaultBrowserType": "chromium" }, "Galaxy A55": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 480, "height": 1040 @@ -209,7 +209,7 @@ "defaultBrowserType": "chromium" }, "Galaxy A55 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 1040, "height": 480 @@ -220,7 +220,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -231,7 +231,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/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -242,7 +242,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S9": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "viewport": { "width": 640, "height": 1024 @@ -253,7 +253,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S9 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "viewport": { "width": 1024, "height": 640 @@ -264,7 +264,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Fold 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 928, "height": 1080 @@ -279,7 +279,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Fold 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 1080, "height": 928 @@ -294,7 +294,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Fold 6 Cover": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 484, "height": 1188 @@ -309,7 +309,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Fold 6 Cover landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F956U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 1188, "height": 484 @@ -324,7 +324,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Fold 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 984, "height": 1092 @@ -339,7 +339,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Fold 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 1092, "height": 984 @@ -354,7 +354,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Fold 7 Cover": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 360, "height": 840 @@ -369,7 +369,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Fold 7 Cover landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F966U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 840, "height": 360 @@ -384,7 +384,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Flip 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 360, "height": 880 @@ -399,7 +399,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Flip 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 880, "height": 360 @@ -414,7 +414,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Flip 6 Cover": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 360, "height": 374 @@ -429,7 +429,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Flip 6 Cover landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F741U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 374, "height": 360 @@ -444,7 +444,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Flip 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 360, "height": 840 @@ -459,7 +459,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Flip 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 840, "height": 360 @@ -474,7 +474,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Flip 7 Cover": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 474, "height": 524 @@ -489,7 +489,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Z Flip 7 Cover landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; SM-F761U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 524, "height": 474 @@ -1748,7 +1748,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1759,7 +1759,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1770,7 +1770,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1781,7 +1781,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1792,7 +1792,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1803,7 +1803,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1814,7 +1814,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/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1825,7 +1825,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/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1836,7 +1836,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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1847,7 +1847,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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1858,7 +1858,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1869,7 +1869,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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1880,7 +1880,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1891,7 +1891,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1902,7 +1902,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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1913,7 +1913,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/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1924,7 +1924,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1935,7 +1935,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1946,7 +1946,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/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1957,7 +1957,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/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -2012,7 +2012,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -2023,7 +2023,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -2034,7 +2034,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -2045,7 +2045,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -2056,7 +2056,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -2067,7 +2067,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/151.0.7922.34 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/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -2078,7 +2078,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -2089,7 +2089,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -2100,7 +2100,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -2115,7 +2115,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -2130,7 +2130,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -2145,7 +2145,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -2160,7 +2160,7 @@ "defaultBrowserType": "chromium" }, "Pixel 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -2175,7 +2175,7 @@ "defaultBrowserType": "chromium" }, "Pixel 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -2190,7 +2190,7 @@ "defaultBrowserType": "chromium" }, "Pixel 6 Pro": { - "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -2205,7 +2205,7 @@ "defaultBrowserType": "chromium" }, "Pixel 6 Pro landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 892, "height": 412 @@ -2220,7 +2220,7 @@ "defaultBrowserType": "chromium" }, "Pixel 6a": { - "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -2235,7 +2235,7 @@ "defaultBrowserType": "chromium" }, "Pixel 6a landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 12; Pixel 6a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -2250,7 +2250,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -2265,7 +2265,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -2280,7 +2280,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 Pro": { - "userAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -2295,7 +2295,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 Pro landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 892, "height": 412 @@ -2310,7 +2310,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7a": { - "userAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -2325,7 +2325,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7a landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -2340,7 +2340,7 @@ "defaultBrowserType": "chromium" }, "Pixel 8": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -2355,7 +2355,7 @@ "defaultBrowserType": "chromium" }, "Pixel 8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -2370,7 +2370,7 @@ "defaultBrowserType": "chromium" }, "Pixel 8 Pro": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 448, "height": 997 @@ -2385,7 +2385,7 @@ "defaultBrowserType": "chromium" }, "Pixel 8 Pro landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 997, "height": 448 @@ -2400,7 +2400,7 @@ "defaultBrowserType": "chromium" }, "Pixel 8a": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -2415,7 +2415,7 @@ "defaultBrowserType": "chromium" }, "Pixel 8a landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 8a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -2430,7 +2430,7 @@ "defaultBrowserType": "chromium" }, "Pixel 9": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 360, "height": 808 @@ -2445,7 +2445,7 @@ "defaultBrowserType": "chromium" }, "Pixel 9 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 808, "height": 360 @@ -2460,7 +2460,7 @@ "defaultBrowserType": "chromium" }, "Pixel 9 Pro": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 427, "height": 952 @@ -2475,7 +2475,7 @@ "defaultBrowserType": "chromium" }, "Pixel 9 Pro landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 952, "height": 427 @@ -2490,7 +2490,7 @@ "defaultBrowserType": "chromium" }, "Pixel 9 Pro XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 448, "height": 997 @@ -2505,7 +2505,7 @@ "defaultBrowserType": "chromium" }, "Pixel 9 Pro XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 9 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 997, "height": 448 @@ -2520,7 +2520,7 @@ "defaultBrowserType": "chromium" }, "Pixel 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 360, "height": 808 @@ -2535,7 +2535,7 @@ "defaultBrowserType": "chromium" }, "Pixel 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 808, "height": 360 @@ -2550,7 +2550,7 @@ "defaultBrowserType": "chromium" }, "Pixel 10 Pro": { - "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 427, "height": 952 @@ -2565,7 +2565,7 @@ "defaultBrowserType": "chromium" }, "Pixel 10 Pro landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 952, "height": 427 @@ -2580,7 +2580,7 @@ "defaultBrowserType": "chromium" }, "Pixel 10 Pro XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 448, "height": 997 @@ -2595,7 +2595,7 @@ "defaultBrowserType": "chromium" }, "Pixel 10 Pro XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 16; Pixel 10 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "screen": { "width": 997, "height": 448 @@ -2610,7 +2610,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -2621,7 +2621,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -2632,7 +2632,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -2647,7 +2647,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Safari/537.36 Edg/151.0.7922.34", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36 Edg/151.0.7922.47", "screen": { "width": 1792, "height": 1120 @@ -2692,7 +2692,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -2707,7 +2707,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Safari/537.36 Edg/151.0.7922.34", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.47 Safari/537.36 Edg/151.0.7922.47", "screen": { "width": 1920, "height": 1080 diff --git a/utils/doclint/cli.js b/utils/doclint/cli.js index 3dc1146727c96..3267d6aa32a4f 100755 --- a/utils/doclint/cli.js +++ b/utils/doclint/cli.js @@ -269,6 +269,11 @@ async function run() { } if (dirtyFiles.size) { + if (process.argv.includes('--allow-dirty')) { + console.log('Regenerated files:'); + [...dirtyFiles].forEach(f => console.log(f)); + process.exit(0); + } console.log('============================') console.log('ERROR: generated files have changed, this is only error if happens in CI:'); [...dirtyFiles].forEach(f => console.log(f)); diff --git a/utils/roll_browser.js b/utils/roll_browser.js index 9000a69e79ea5..a177d44dc8e06 100755 --- a/utils/roll_browser.js +++ b/utils/roll_browser.js @@ -149,11 +149,7 @@ Example: // 8. Update docs. console.log('\nUpdating documentation...'); - try { - execSync('npm run doc', { stdio: 'inherit' }); - } catch (e) { - console.log('npm run doc failed with non-zero exit code. This might have updated generated files.'); - } + execSync('npm run doc -- --allow-dirty', { stdio: 'inherit' }); } console.log(`\nRolled ${browserName} to ${revision}`); })().catch(err => { From 63ff5dc1b3a379a07da51315ff439dcea3bc479b Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 28 Jul 2026 08:44:43 -0700 Subject: [PATCH 4/6] fix(runner): do not force-kill worker while its teardown is in progress (#42010) --- packages/playwright/src/common/process.ts | 6 +++++ packages/playwright/src/runner/processHost.ts | 24 +++++++++++++++---- tests/playwright-test/exit-code.spec.ts | 21 ++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/playwright/src/common/process.ts b/packages/playwright/src/common/process.ts index 57c1e99c5d106..1d65cf1146d0b 100644 --- a/packages/playwright/src/common/process.ts +++ b/packages/playwright/src/common/process.ts @@ -105,6 +105,7 @@ export function startProcessRunner(create: (params: any) => ProcessRunner) { } const kForceExitTimeout = +(process.env.PWTEST_FORCE_EXIT_TIMEOUT || 30000); +const kHeartbeatInterval = 1000; async function gracefullyCloseAndExit(forceExit: boolean) { if (forceExit && !forceExitInitiated) { @@ -115,8 +116,13 @@ async function gracefullyCloseAndExit(forceExit: boolean) { } if (!gracefullyCloseCalled) { gracefullyCloseCalled = true; + // Heartbeats tell the parent that graceful close is still running, e.g. a fixture + // teardown with "timeout: 0", as opposed to a hung process that must be force-killed. + const heartbeat = setInterval(() => sendMessageToParent({ method: '__heartbeat__' }), kHeartbeatInterval); + heartbeat.unref(); // Meanwhile, try to gracefully shutdown. await processRunner?.gracefullyClose().catch(() => {}); + clearInterval(heartbeat); if (processName) await stopProfiling(processName).catch(() => {}); // eslint-disable-next-line no-restricted-properties diff --git a/packages/playwright/src/runner/processHost.ts b/packages/playwright/src/runner/processHost.ts index 5c0257933f8a8..b812b700240df 100644 --- a/packages/playwright/src/runner/processHost.ts +++ b/packages/playwright/src/runner/processHost.ts @@ -170,11 +170,25 @@ export class ProcessHost extends EventEmitter { return; const exitPromise = new Promise(f => this.once('exit', () => f())); const timeout = +(process.env.PWTEST_CHILD_PROCESS_TIMEOUT || 5 * 60 * 1000); - const result = await raceAgainstDeadline(() => exitPromise, monotonicTime() + timeout); - if (result.timedOut) { - this.emit('processError', { message: `Error: ${this._processName} process did not exit within ${timeout}ms after stop, force-killed it` }); - this._forceKill(); - await exitPromise; + // Child sends heartbeats while gracefully closing, e.g. running a slow fixture + // teardown with "timeout: 0". Only force-kill when heartbeats stop coming. + let lastHeartbeat = monotonicTime(); + const onHeartbeat = () => lastHeartbeat = monotonicTime(); + this.on('__heartbeat__', onHeartbeat); + try { + while (true) { + const result = await raceAgainstDeadline(() => exitPromise, lastHeartbeat + timeout); + if (!result.timedOut) + return; + if (monotonicTime() < lastHeartbeat + timeout) + continue; + this.emit('processError', { message: `Error: ${this._processName} process did not exit within ${timeout}ms after stop, force-killed it` }); + this._forceKill(); + await exitPromise; + return; + } + } finally { + this.off('__heartbeat__', onHeartbeat); } } diff --git a/tests/playwright-test/exit-code.spec.ts b/tests/playwright-test/exit-code.spec.ts index 82b4f0d7db60b..44d6238930aec 100644 --- a/tests/playwright-test/exit-code.spec.ts +++ b/tests/playwright-test/exit-code.spec.ts @@ -230,3 +230,24 @@ test('should force-kill a worker that does not exit on stop', async ({ runInline // Should complete well within a minute thanks to the watchdog. expect(monotonicTime() - now).toBeLessThan(60000); }); + +test('should not force-kill a worker that is running a slow fixture teardown', async ({ runInlineTest }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42007' }); + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend<{}, { slowTeardown: void }>({ + slowTeardown: [async ({}, use) => { + await use(); + await new Promise(f => setTimeout(f, 4000)); + console.log('slow teardown finished'); + }, { scope: 'worker', timeout: 0 }], + }); + test('passes', async ({ slowTeardown }) => {}); + `, + }, undefined, { PWTEST_CHILD_PROCESS_TIMEOUT: '2000' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).toContain('slow teardown finished'); + expect(result.output).not.toContain('force-killed'); +}); From 05709a54bcf72371b6bf515d36a116d2fb246d2f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 28 Jul 2026 16:46:59 +0100 Subject: [PATCH 5/6] fix(selectors): respect scope when piercing frames (#42018) --- docs/src/api/class-frame.md | 8 +-- packages/playwright-client/types/types.d.ts | 8 +-- .../src/server/frameSelectors.ts | 26 ++++---- packages/playwright-core/types/types.d.ts | 8 +-- tests/page/locator-pierce-frames.spec.ts | 62 +++++++++++++++++++ 5 files changed, 87 insertions(+), 25 deletions(-) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 5295065f84a76..50173bbb8b27a 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -1443,16 +1443,16 @@ Parent frame, if any. Detached frames and main frames return `null`. * since: v1.63 - returns: <[FrameLocator]> -When working with iframes, you can create a frame locator that will search for elements in the main frame -and in all iframes on the page, so that you don't need to locate each iframe first. +When working with iframes, you can create a frame locator that will search for elements in this frame +and in all iframes inside it, so that you don't need to locate each iframe first. -Note that all elements matching the locator must belong to a single frame. For example, if the page contains +Note that all elements matching the locator must belong to a single frame. For example, if the frame contains two iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches elements from multiple frames. **Usage** -Following snippet locates a button, either in the main frame or in one of the iframes: +Following snippet locates a button, either in the frame or in one of the iframes inside it: ```js const locator = frame.pierceFrames().getByRole('button'); diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index baa5f1d759c4a..05f08a7a45b94 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -8166,16 +8166,16 @@ export interface Frame { parentFrame(): null|Frame; /** - * When working with iframes, you can create a frame locator that will search for elements in the main frame and in - * all iframes on the page, so that you don't need to locate each iframe first. + * When working with iframes, you can create a frame locator that will search for elements in this frame and in all + * iframes inside it, so that you don't need to locate each iframe first. * - * Note that all elements matching the locator must belong to a single frame. For example, if the page contains two + * Note that all elements matching the locator must belong to a single frame. For example, if the frame contains two * iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches * elements from multiple frames. * * **Usage** * - * Following snippet locates a button, either in the main frame or in one of the iframes: + * Following snippet locates a button, either in the frame or in one of the iframes inside it: * * ```js * const locator = frame.pierceFrames().getByRole('button'); diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index 27f6d7b554e6b..7233ac85a7422 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -181,18 +181,17 @@ export class FrameSelectors { private async _resolveFramePiercingSelector(parsed: ParsedSelector, options: types.StrictOptions, scope: ElementHandle | undefined) { const candidates = new Map>(); const infos = parsed.parts.map(part => this._parseSelector({ parts: [part] }, options)); - for (const frame of this.frame._page.frameManager.frames()) - await this._pierceFramesRecursivelyIfNotSeen(frame, infos, scope, 0, candidates); + await this._pierceFramesRecursivelyIfNotSeen(this.frame, infos, scope, 0, candidates); const result: SelectorInFrame[] = []; - for (const [frame, matches] of candidates) { - for (const match of matches) { - const suffix = infos.slice(match); + for (const [frame, startIndexes] of candidates) { + for (const startIndex of startIndexes) { + const suffix = infos.slice(startIndex); const partialInfo: SelectorInfo = { parsed: { parts: suffix.map(info => info.parsed.parts[0]) }, world: suffix.some(info => info.world === 'main') ? 'main' : 'utility', strict: !!options.strict, }; - result.push({ frame, info: partialInfo }); + result.push({ frame, info: partialInfo, scope: frame === this.frame && startIndex === 0 ? scope : undefined }); } } return result; @@ -206,7 +205,7 @@ export class FrameSelectors { } if (!set.has(startIndex)) { set.add(startIndex); - await this._pierceFramesRecursively(frame, infos, undefined, startIndex, result); + await this._pierceFramesRecursively(frame, infos, scope, startIndex, result); } } @@ -215,7 +214,8 @@ export class FrameSelectors { const injected = await context.injectedScript(); const frameCandidatesHandle = await injected.evaluateHandle((injected, { infos, scope, startIndex }) => { const frameElements = injected.querySelectorAll(injected.parseSelector('css=frame,iframe'), scope || document); - const result = frameElements.map(frameElement => ({ frameElement, matches: [] as number[] })); + // Any frame inside the search root may contain the whole selector suffix, so seed with startIndex. + const result = frameElements.map(frameElement => ({ frameElement, nextIndexes: [startIndex] })); let roots = [scope || document]; for (let index = startIndex; index < infos.length; index++) { @@ -227,9 +227,9 @@ export class FrameSelectors { } roots = [...next]; if (index + 1 < infos.length && !['nth', 'visible'].includes(infos[index + 1].parsed.parts[0].name)) { - for (const { frameElement, matches } of result) { + for (const { frameElement, nextIndexes } of result) { if (roots.some(root => injected.utils.isInsideScope(root, frameElement))) - matches.push(index); + nextIndexes.push(index + 1); } } } @@ -242,9 +242,9 @@ export class FrameSelectors { const frameElement = await frameCandidatesHandle.evaluateHandle((list, i) => list[i].frameElement, i) as ElementHandle; const childFrame = await frame._page.delegate.getContentFrame(frameElement).catch(() => null); if (childFrame) { - const matches = await frameCandidatesHandle.evaluate((list, i) => list[i].matches, i) as number[]; - for (const match of matches) - await this._pierceFramesRecursivelyIfNotSeen(childFrame, infos, undefined, match + 1, result); + const nextIndexes = await frameCandidatesHandle.evaluate((list, i) => list[i].nextIndexes, i) as number[]; + for (const nextIndex of nextIndexes) + await this._pierceFramesRecursivelyIfNotSeen(childFrame, infos, undefined, nextIndex, result); } } catch { // Ignore errors for this frame candidate. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index baa5f1d759c4a..05f08a7a45b94 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -8166,16 +8166,16 @@ export interface Frame { parentFrame(): null|Frame; /** - * When working with iframes, you can create a frame locator that will search for elements in the main frame and in - * all iframes on the page, so that you don't need to locate each iframe first. + * When working with iframes, you can create a frame locator that will search for elements in this frame and in all + * iframes inside it, so that you don't need to locate each iframe first. * - * Note that all elements matching the locator must belong to a single frame. For example, if the page contains two + * Note that all elements matching the locator must belong to a single frame. For example, if the frame contains two * iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches * elements from multiple frames. * * **Usage** * - * Following snippet locates a button, either in the main frame or in one of the iframes: + * Following snippet locates a button, either in the frame or in one of the iframes inside it: * * ```js * const locator = frame.pierceFrames().getByRole('button'); diff --git a/tests/page/locator-pierce-frames.spec.ts b/tests/page/locator-pierce-frames.spec.ts index 08dcba04932e3..5ad3bd4a5be8a 100644 --- a/tests/page/locator-pierce-frames.spec.ts +++ b/tests/page/locator-pierce-frames.spec.ts @@ -237,6 +237,68 @@ it('should support first/last/nth as the last operation', async ({ page, server await expect(page.pierceFrames().locator('span').nth(1)).toHaveText('two'); }); +it('should pierce a frame inside the scope', async ({ page, server }) => { + await routePage(page, 'empty.html', `
`); + await routePage(page, 'a.html', ``); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'button'); + const scope = (await page.$('section'))!; + const buttons = await scope.$$('internal:control=pierce-frames >> button'); + expect(buttons.length).toBe(1); + expect(await buttons[0].textContent()).toBe('inside'); +}); + +it('should pierce a nested frame inside the scope', async ({ page, server }) => { + await routePage(page, 'empty.html', `
`); + await routePage(page, 'a.html', ``); + await routePage(page, 'b.html', ``); + await routePage(page, 'c.html', ``); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.frames().length).toBe(4); + for (const frame of page.frames()) { + if (frame.url().includes('b.html') || frame.url().includes('c.html')) + await frame.waitForSelector('button', { state: 'attached' }); + } + const scope = (await page.$('section'))!; + const buttons = await scope.$$('internal:control=pierce-frames >> button'); + expect(buttons.length).toBe(1); + expect(await buttons[0].textContent()).toBe('deep'); +}); + +it('should respect the scope without a frame inside the scope', async ({ page, server }) => { + await routePage(page, 'empty.html', `
`); + await routePage(page, 'a.html', ``); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'button'); + const scope = (await page.$('section'))!; + const buttons = await scope.$$('internal:control=pierce-frames >> button'); + expect(buttons.length).toBe(1); + expect(await buttons[0].textContent()).toBe('target'); +}); + +it('should pierce nested frames below a matching prefix', async ({ page, server }) => { + await routePage(page, 'empty.html', `
`); + await routePage(page, 'a.html', ``); + await routePage(page, 'b.html', ``); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.frames().length).toBe(3); + const deepFrame = page.frames().find(f => f.url().includes('b.html'))!; + await deepFrame.waitForSelector('button', { state: 'attached' }); + await expect(page.pierceFrames().locator('section').locator('button')).toHaveText('deep'); +}); + +it('should pierce only frames inside the starting frame', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', ``); + await routePage(page, 'b.html', ``); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.frames().length).toBe(3); + const deepFrame = page.frames().find(f => f.url().includes('b.html'))!; + await deepFrame.waitForSelector('button', { state: 'attached' }); + const middleFrame = page.frames().find(f => f.url().includes('a.html'))!; + await expect(middleFrame.pierceFrames().locator('button')).toHaveText('deep'); +}); + it('should not allow nth in the middle', async ({ page }) => { const error = await page.pierceFrames().locator('div').first().locator('span').count().catch(e => e); expect(error.message).toContain(`nth can only be the last locator when piercing frames, while querying "pierceFrames().locator('div').first().locator('span')"`); From 974edd3feb4adbbfca6ca741adf205cf87df2282 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 28 Jul 2026 16:51:57 +0100 Subject: [PATCH 6/6] feat(dialog): add dialogclosed event (#42014) --- docs/src/api/class-browsercontext.md | 6 ++ docs/src/api/class-page.md | 6 ++ packages/playwright-client/types/types.d.ts | 94 +++++++++++++++++++ .../src/client/browserContext.ts | 8 ++ .../playwright-core/src/client/channels.d.ts | 9 +- packages/playwright-core/src/client/events.ts | 2 + packages/playwright-core/src/client/page.ts | 1 + .../src/server/bidi/bidiPage.ts | 5 + .../playwright-core/src/server/channels.d.ts | 9 +- .../src/server/chromium/crPage.ts | 5 + packages/playwright-core/src/server/dialog.ts | 32 +++++++ .../dispatchers/browserContextDispatcher.ts | 9 ++ .../src/server/firefox/ffPage.ts | 5 + .../src/server/webkit/wkPage.ts | 5 + packages/playwright-core/types/types.d.ts | 94 +++++++++++++++++++ packages/protocol/spec/browserContext.yml | 5 + packages/protocol/spec/page.yml | 1 + packages/protocol/src/validator.ts | 7 +- tests/library/browsercontext-events.spec.ts | 13 +++ tests/library/chromium/chromium.spec.ts | 14 +++ tests/page/page-dialog.spec.ts | 35 +++++++ 21 files changed, 359 insertions(+), 6 deletions(-) diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 68f0c531b3e3c..0859c935d4147 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -198,6 +198,12 @@ Context.Dialog += async (_, dialog) => When no [`event: Page.dialog`] or [`event: BrowserContext.dialog`] listeners are present, all dialogs are automatically dismissed. ::: +## event: BrowserContext.dialogClosed +* since: v1.63 +- argument: <[Dialog]> + +Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by [`method: Dialog.accept`], by [`method: Dialog.dismiss`], or manually by the user in the headed browser. + ## event: BrowserContext.download * since: v1.60 - argument: <[Download]> diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index bb0b594f61ce5..d631c89a16547 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -319,6 +319,12 @@ page.RequestFailed += (_, request) => When no [`event: Page.dialog`] or [`event: BrowserContext.dialog`] listeners are present, all dialogs are automatically dismissed. ::: +## event: Page.dialogClosed +* since: v1.63 +- argument: <[Dialog]> + +Emitted when a JavaScript dialog has been closed, either by [`method: Dialog.accept`], by [`method: Dialog.dismiss`], or manually by the user in the headed browser. + ## event: Page.DOMContentLoaded * since: v1.9 - argument: <[Page]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 05f08a7a45b94..b1bc91b00b2a7 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -1046,6 +1046,14 @@ export interface Page { */ on(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + on(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when the JavaScript * [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. @@ -1207,6 +1215,11 @@ export interface Page { */ once(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -1348,6 +1361,14 @@ export interface Page { */ addListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + addListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when the JavaScript * [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. @@ -1509,6 +1530,11 @@ export interface Page { */ removeListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -1604,6 +1630,11 @@ export interface Page { */ off(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -1745,6 +1776,14 @@ export interface Page { */ prependListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + prependListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when the JavaScript * [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. @@ -5287,6 +5326,14 @@ export interface Page { */ waitForEvent(event: 'dialog', optionsOrPredicate?: { predicate?: (dialog: Dialog) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((dialog: Dialog) => boolean | Promise)): Promise; + /** + * Emitted when a JavaScript dialog has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + waitForEvent(event: 'dialogclosed', optionsOrPredicate?: { predicate?: (dialog: Dialog) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((dialog: Dialog) => boolean | Promise)): Promise; + /** * Emitted when the JavaScript * [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. @@ -9222,6 +9269,14 @@ export interface BrowserContext { */ on(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + on(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when attachment download started in any page belonging to this context. User can access basic file * operations on downloaded content via the passed [Download](https://playwright.dev/docs/api/class-download) @@ -9367,6 +9422,11 @@ export interface BrowserContext { */ once(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -9488,6 +9548,14 @@ export interface BrowserContext { */ addListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + addListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when attachment download started in any page belonging to this context. User can access basic file * operations on downloaded content via the passed [Download](https://playwright.dev/docs/api/class-download) @@ -9633,6 +9701,11 @@ export interface BrowserContext { */ removeListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -9718,6 +9791,11 @@ export interface BrowserContext { */ off(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -9839,6 +9917,14 @@ export interface BrowserContext { */ prependListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + prependListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when attachment download started in any page belonging to this context. User can access basic file * operations on downloaded content via the passed [Download](https://playwright.dev/docs/api/class-download) @@ -10703,6 +10789,14 @@ export interface BrowserContext { */ waitForEvent(event: 'dialog', optionsOrPredicate?: { predicate?: (dialog: Dialog) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((dialog: Dialog) => boolean | Promise)): Promise; + /** + * Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + waitForEvent(event: 'dialogclosed', optionsOrPredicate?: { predicate?: (dialog: Dialog) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((dialog: Dialog) => boolean | Promise)): Promise; + /** * Emitted when attachment download started in any page belonging to this context. User can access basic file * operations on downloaded content via the passed [Download](https://playwright.dev/docs/api/class-download) diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index c971f0739048f..c54f131d4f7a5 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -156,6 +156,13 @@ export class BrowserContext extends ChannelOwner dialog.dismiss({}, kNoTimeout).catch(() => {}); } }); + this._channel.on('dialogClosed', ({ dialog }) => { + const dialogObject = Dialog.from(dialog); + this.emit(Events.BrowserContext.DialogClosed, dialogObject); + const page = dialogObject.page(); + if (page) + page.emit(Events.Page.DialogClosed, dialogObject); + }); this._channel.on('request', ({ request, page }) => this._onRequest(network.Request.from(request), Page.fromNullable(page))); this._channel.on('requestFailed', ({ request, failureText, responseEndTiming, page }) => this._onRequestFailed(network.Request.from(request), responseEndTiming, failureText, Page.fromNullable(page))); this._channel.on('requestFinished', params => this._onRequestFinished(params)); @@ -171,6 +178,7 @@ export class BrowserContext extends ChannelOwner this._setEventToSubscriptionMapping(new Map([ [Events.BrowserContext.Console, 'console'], [Events.BrowserContext.Dialog, 'dialog'], + [Events.BrowserContext.DialogClosed, 'dialogClosed'], [Events.BrowserContext.Request, 'request'], [Events.BrowserContext.Response, 'response'], [Events.BrowserContext.RequestFinished, 'requestFinished'], diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index be860f3deb3ec..02035b33e25b4 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -1210,6 +1210,7 @@ export interface BrowserContextEventTarget { on(event: 'console', callback: (params: BrowserContextConsoleEvent) => void): this; on(event: 'close', callback: (params: BrowserContextCloseEvent) => void): this; on(event: 'dialog', callback: (params: BrowserContextDialogEvent) => void): this; + on(event: 'dialogClosed', callback: (params: BrowserContextDialogClosedEvent) => void): this; on(event: 'page', callback: (params: BrowserContextPageEvent) => void): this; on(event: 'pageError', callback: (params: BrowserContextPageErrorEvent) => void): this; on(event: 'route', callback: (params: BrowserContextRouteEvent) => void): this; @@ -1281,6 +1282,9 @@ export type BrowserContextCloseEvent = {}; export type BrowserContextDialogEvent = { dialog: DialogChannel, }; +export type BrowserContextDialogClosedEvent = { + dialog: DialogChannel, +}; export type BrowserContextPageEvent = { page: PageChannel, }; @@ -1575,7 +1579,7 @@ export type BrowserContextCreateTempFilesResult = { writableStreams: WritableStreamChannel[], }; export type BrowserContextUpdateSubscriptionParams = { - event: 'console' | 'dialog' | 'request' | 'response' | 'requestFinished' | 'requestFailed', + event: 'console' | 'dialog' | 'dialogClosed' | 'request' | 'response' | 'requestFinished' | 'requestFailed', enabled: boolean, }; export type BrowserContextUpdateSubscriptionOptions = { @@ -1682,6 +1686,7 @@ export interface BrowserContextEvents { 'console': BrowserContextConsoleEvent; 'close': BrowserContextCloseEvent; 'dialog': BrowserContextDialogEvent; + 'dialogClosed': BrowserContextDialogClosedEvent; 'page': BrowserContextPageEvent; 'pageError': BrowserContextPageErrorEvent; 'route': BrowserContextRouteEvent; @@ -4462,7 +4467,7 @@ export type PageScreencastStopParams = {}; export type PageScreencastStopOptions = {}; export type PageScreencastStopResult = void; export type PageUpdateSubscriptionParams = { - event: 'console' | 'dialog' | 'fileChooser' | 'request' | 'response' | 'requestFinished' | 'requestFailed', + event: 'console' | 'dialog' | 'dialogClosed' | 'fileChooser' | 'request' | 'response' | 'requestFinished' | 'requestFailed', enabled: boolean, }; export type PageUpdateSubscriptionOptions = { diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index 86ee9201d13cb..0233d000fe9cd 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -44,6 +44,7 @@ export const Events = { Console: 'console', Close: 'close', Dialog: 'dialog', + DialogClosed: 'dialogclosed', Download: 'download', FrameAttached: 'frameattached', FrameDetached: 'framedetached', @@ -71,6 +72,7 @@ export const Events = { Crash: 'crash', Console: 'console', Dialog: 'dialog', + DialogClosed: 'dialogclosed', Download: 'download', FileChooser: 'filechooser', DOMContentLoaded: 'domcontentloaded', diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 957507a151b05..c6dd7f79372e6 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -178,6 +178,7 @@ export class Page extends ChannelOwner implements api.Page this._setEventToSubscriptionMapping(new Map([ [Events.Page.Console, 'console'], [Events.Page.Dialog, 'dialog'], + [Events.Page.DialogClosed, 'dialogClosed'], [Events.Page.Request, 'request'], [Events.Page.Response, 'response'], [Events.Page.RequestFinished, 'requestFinished'], diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 33825079447d9..4245b07a587e8 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -89,6 +89,7 @@ export class BidiPage implements PageDelegate { eventsHelper.addEventListener(bidiSession, 'browsingContext.downloadWillBegin', this._onDownloadWillBegin.bind(this)), eventsHelper.addEventListener(bidiSession, 'browsingContext.downloadEnd', this._onDownloadEnded.bind(this)), eventsHelper.addEventListener(bidiSession, 'browsingContext.userPromptOpened', this._onUserPromptOpened.bind(this)), + eventsHelper.addEventListener(bidiSession, 'browsingContext.userPromptClosed', this._onUserPromptClosed.bind(this)), eventsHelper.addEventListener(bidiSession, 'log.entryAdded', this._onLogEntryAdded.bind(this)), eventsHelper.addEventListener(bidiSession, 'input.fileDialogOpened', this._onFileDialogOpened.bind(this)), ]; @@ -257,6 +258,10 @@ export class BidiPage implements PageDelegate { event.defaultValue)); } + private _onUserPromptClosed() { + this._page.browserContext.dialogManager.dialogWasClosedInBrowser(this._page); + } + private _onDownloadWillBegin(event: bidi.BrowsingContext.DownloadWillBeginParams) { // TODO: remove the event.navigation fallback when Chrome supports event.download // See https://github.com/GoogleChromeLabs/chromium-bidi/issues/4155 diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index effc7d3986ccc..f2eec79b36eaa 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -1211,6 +1211,7 @@ export interface BrowserContextEventTarget { _dispatchEvent(event: 'console', params?: BrowserContextConsoleEvent): void; _dispatchEvent(event: 'close', params?: BrowserContextCloseEvent): void; _dispatchEvent(event: 'dialog', params?: BrowserContextDialogEvent): void; + _dispatchEvent(event: 'dialogClosed', params?: BrowserContextDialogClosedEvent): void; _dispatchEvent(event: 'page', params?: BrowserContextPageEvent): void; _dispatchEvent(event: 'pageError', params?: BrowserContextPageErrorEvent): void; _dispatchEvent(event: 'route', params?: BrowserContextRouteEvent): void; @@ -1282,6 +1283,9 @@ export type BrowserContextCloseEvent = {}; export type BrowserContextDialogEvent = { dialog: DialogChannel, }; +export type BrowserContextDialogClosedEvent = { + dialog: DialogChannel, +}; export type BrowserContextPageEvent = { page: PageChannel, }; @@ -1576,7 +1580,7 @@ export type BrowserContextCreateTempFilesResult = { writableStreams: WritableStreamChannel[], }; export type BrowserContextUpdateSubscriptionParams = { - event: 'console' | 'dialog' | 'request' | 'response' | 'requestFinished' | 'requestFailed', + event: 'console' | 'dialog' | 'dialogClosed' | 'request' | 'response' | 'requestFinished' | 'requestFailed', enabled: boolean, }; export type BrowserContextUpdateSubscriptionOptions = { @@ -1683,6 +1687,7 @@ export interface BrowserContextEvents { 'console': BrowserContextConsoleEvent; 'close': BrowserContextCloseEvent; 'dialog': BrowserContextDialogEvent; + 'dialogClosed': BrowserContextDialogClosedEvent; 'page': BrowserContextPageEvent; 'pageError': BrowserContextPageErrorEvent; 'route': BrowserContextRouteEvent; @@ -4463,7 +4468,7 @@ export type PageScreencastStopParams = {}; export type PageScreencastStopOptions = {}; export type PageScreencastStopResult = void; export type PageUpdateSubscriptionParams = { - event: 'console' | 'dialog' | 'fileChooser' | 'request' | 'response' | 'requestFinished' | 'requestFailed', + event: 'console' | 'dialog' | 'dialogClosed' | 'fileChooser' | 'request' | 'response' | 'requestFinished' | 'requestFailed', enabled: boolean, }; export type PageUpdateSubscriptionOptions = { diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index b462b0b226eeb..12d91c0fc1bd4 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -417,6 +417,7 @@ class FrameSession { eventsHelper.addEventListener(this._client, 'Page.frameNavigated', event => this._onFrameNavigated(event.frame, false)), eventsHelper.addEventListener(this._client, 'Page.frameRequestedNavigation', event => this._onFrameRequestedNavigation(event)), eventsHelper.addEventListener(this._client, 'Page.javascriptDialogOpening', event => this._onDialog(event)), + eventsHelper.addEventListener(this._client, 'Page.javascriptDialogClosed', () => this._onDialogClosed()), eventsHelper.addEventListener(this._client, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)), eventsHelper.addEventListener(this._client, 'Runtime.bindingCalled', event => this._onBindingCalled(event)), eventsHelper.addEventListener(this._client, 'Runtime.consoleAPICalled', event => this._onConsoleAPI(event)), @@ -842,6 +843,10 @@ class FrameSession { event.defaultPrompt)); } + _onDialogClosed() { + this._page.browserContext.dialogManager.dialogWasClosedInBrowser(this._page); + } + _handleException(exceptionDetails: Protocol.Runtime.ExceptionDetails) { this._page.addPageError(exceptionToError(exceptionDetails), stackTraceToLocation(exceptionDetails.stackTrace)); } diff --git a/packages/playwright-core/src/server/dialog.ts b/packages/playwright-core/src/server/dialog.ts index e25db0f6484c2..40039c032289b 100644 --- a/packages/playwright-core/src/server/dialog.ts +++ b/packages/playwright-core/src/server/dialog.ts @@ -33,6 +33,7 @@ export class Dialog extends SdkObject { private _message: string; private _onHandle: OnHandle; private _handled = false; + private _closed = false; private _defaultValue: string; constructor(page: Page, type: DialogType, message: string, onHandle: OnHandle, defaultValue?: string) { @@ -73,6 +74,7 @@ export class Dialog extends SdkObject { this._handled = true; this._page.browserContext.dialogManager._dialogWillClose(this); await this._onHandle(true, promptText); + this._didClose(); } async _dismiss() { @@ -80,6 +82,14 @@ export class Dialog extends SdkObject { this._handled = true; this._page.browserContext.dialogManager._dialogWillClose(this); await this._onHandle(false); + this._didClose(); + } + + _didClose() { + if (this._closed) + return; + this._closed = true; + this._page.browserContext.dialogManager._dialogDidClose(this); } async _close() { @@ -93,6 +103,7 @@ export class Dialog extends SdkObject { export class DialogManager { private _instrumentation: Instrumentation; private _dialogHandlers = new Set<(dialog: Dialog) => boolean>(); + private _dialogClosedListeners = new Set<(dialog: Dialog) => void>(); private _openedDialogs = new Set(); constructor(instrumentation: Instrumentation) { @@ -119,6 +130,27 @@ export class DialogManager { this._openedDialogs.delete(dialog); } + _dialogDidClose(dialog: Dialog) { + this._openedDialogs.delete(dialog); + for (const listener of this._dialogClosedListeners) + listener(dialog); + } + + dialogWasClosedInBrowser(page: Page) { + for (const dialog of this._openedDialogs) { + if (dialog.page() === page) + dialog._didClose(); + } + } + + addDialogClosedListener(listener: (dialog: Dialog) => void) { + this._dialogClosedListeners.add(listener); + } + + removeDialogClosedListener(listener: (dialog: Dialog) => void) { + this._dialogClosedListeners.delete(listener); + } + addDialogHandler(handler: (dialog: Dialog) => boolean) { this._dialogHandlers.add(handler); } diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 215dd68a5dce5..2560f06a02553 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -57,6 +57,7 @@ export class BrowserContextDispatcher extends Dispatcher boolean; + private _dialogClosedListener: (dialog: Dialog) => void; private _clockPaused = false; private _requestInterceptor: RouteHandler; private _interceptionUrlMatchers: URLMatch[] = []; @@ -138,6 +139,13 @@ export class BrowserContextDispatcher extends Dispatcher { + if (!this._shouldDispatchEvent(dialog.page(), 'dialogClosed')) + return; + const dialogDispatcher = this.connection.existingDispatcher(dialog) || new DialogDispatcher(this, dialog); + this._dispatchEvent('dialogClosed', { dialog: dialogDispatcher }); + }; + context.dialogManager.addDialogClosedListener(this._dialogClosedListener); if (context._browser.options.name === 'chromium' && this._object._browser instanceof CRBrowser) { for (const serviceWorker of (context as CRBrowserContext).serviceWorkers()) @@ -444,6 +452,7 @@ export class BrowserContextDispatcher extends Dispatcher {}); disposeAll(this._disposables).catch(() => {}); diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 86c5031367bcb..0449f341b97ee 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -86,6 +86,7 @@ export class FFPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Page.uncaughtError', this._onUncaughtError.bind(this)), eventsHelper.addEventListener(this._session, 'Runtime.console', this._onConsole.bind(this)), eventsHelper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)), + eventsHelper.addEventListener(this._session, 'Page.dialogClosed', this._onDialogClosed.bind(this)), eventsHelper.addEventListener(this._session, 'Page.bindingCalled', this._onBindingCalled.bind(this)), eventsHelper.addEventListener(this._session, 'Page.fileChooserOpened', this._onFileChooserOpened.bind(this)), eventsHelper.addEventListener(this._session, 'Page.workerCreated', this._onWorkerCreated.bind(this)), @@ -301,6 +302,10 @@ export class FFPage implements PageDelegate { params.defaultValue)); } + _onDialogClosed() { + this._page.browserContext.dialogManager.dialogWasClosedInBrowser(this._page); + } + async _onBindingCalled(event: Protocol.Page.bindingCalledPayload) { const pageOrError = await this._page.waitForInitializedOrError(); if (!(pageOrError instanceof Error)) { diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 8e6dc38672381..c694d28404bcb 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -390,6 +390,7 @@ export class WKPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Console.messageAdded', event => this._onConsoleMessage(event)), eventsHelper.addEventListener(this._session, 'Console.messageRepeatCountUpdated', event => this._onConsoleRepeatCountUpdated(event)), eventsHelper.addEventListener(this._pageProxySession, 'Dialog.javascriptDialogOpening', event => this._onDialog(event)), + eventsHelper.addEventListener(this._pageProxySession, 'Dialog.javascriptDialogClosed', () => this._onDialogClosed()), eventsHelper.addEventListener(this._session, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event)), eventsHelper.addEventListener(this._session, 'Network.requestWillBeSent', e => this._onRequestWillBeSent(this._session, e)), eventsHelper.addEventListener(this._session, 'Network.requestIntercepted', e => this._onRequestIntercepted(this._session, e)), @@ -625,6 +626,10 @@ export class WKPage implements PageDelegate { event.defaultPrompt)); } + _onDialogClosed() { + this._page.browserContext.dialogManager.dialogWasClosedInBrowser(this._page); + } + private async _onFileChooserOpened(event: {frameId: Protocol.Network.FrameId, element: Protocol.Runtime.RemoteObject}) { let handle; try { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 05f08a7a45b94..b1bc91b00b2a7 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -1046,6 +1046,14 @@ export interface Page { */ on(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + on(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when the JavaScript * [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. @@ -1207,6 +1215,11 @@ export interface Page { */ once(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -1348,6 +1361,14 @@ export interface Page { */ addListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + addListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when the JavaScript * [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. @@ -1509,6 +1530,11 @@ export interface Page { */ removeListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -1604,6 +1630,11 @@ export interface Page { */ off(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -1745,6 +1776,14 @@ export interface Page { */ prependListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + prependListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when the JavaScript * [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. @@ -5287,6 +5326,14 @@ export interface Page { */ waitForEvent(event: 'dialog', optionsOrPredicate?: { predicate?: (dialog: Dialog) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((dialog: Dialog) => boolean | Promise)): Promise; + /** + * Emitted when a JavaScript dialog has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + waitForEvent(event: 'dialogclosed', optionsOrPredicate?: { predicate?: (dialog: Dialog) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((dialog: Dialog) => boolean | Promise)): Promise; + /** * Emitted when the JavaScript * [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. @@ -9222,6 +9269,14 @@ export interface BrowserContext { */ on(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + on(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when attachment download started in any page belonging to this context. User can access basic file * operations on downloaded content via the passed [Download](https://playwright.dev/docs/api/class-download) @@ -9367,6 +9422,11 @@ export interface BrowserContext { */ once(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -9488,6 +9548,14 @@ export interface BrowserContext { */ addListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + addListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when attachment download started in any page belonging to this context. User can access basic file * operations on downloaded content via the passed [Download](https://playwright.dev/docs/api/class-download) @@ -9633,6 +9701,11 @@ export interface BrowserContext { */ removeListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -9718,6 +9791,11 @@ export interface BrowserContext { */ off(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -9839,6 +9917,14 @@ export interface BrowserContext { */ prependListener(event: 'dialog', listener: (dialog: Dialog) => any): this; + /** + * Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + prependListener(event: 'dialogclosed', listener: (dialog: Dialog) => any): this; + /** * Emitted when attachment download started in any page belonging to this context. User can access basic file * operations on downloaded content via the passed [Download](https://playwright.dev/docs/api/class-download) @@ -10703,6 +10789,14 @@ export interface BrowserContext { */ waitForEvent(event: 'dialog', optionsOrPredicate?: { predicate?: (dialog: Dialog) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((dialog: Dialog) => boolean | Promise)): Promise; + /** + * Emitted when a JavaScript dialog in any page belonging to this context has been closed, either by + * [dialog.accept([promptText])](https://playwright.dev/docs/api/class-dialog#dialog-accept), by + * [dialog.dismiss()](https://playwright.dev/docs/api/class-dialog#dialog-dismiss), or manually by the user in the + * headed browser. + */ + waitForEvent(event: 'dialogclosed', optionsOrPredicate?: { predicate?: (dialog: Dialog) => boolean | Promise, timeout?: number, signal?: AbortSignal } | ((dialog: Dialog) => boolean | Promise)): Promise; + /** * Emitted when attachment download started in any page belonging to this context. User can access basic file * operations on downloaded content via the passed [Download](https://playwright.dev/docs/api/class-download) diff --git a/packages/protocol/spec/browserContext.yml b/packages/protocol/spec/browserContext.yml index 5c1505513ccf6..f7d96dbe2e526 100644 --- a/packages/protocol/spec/browserContext.yml +++ b/packages/protocol/spec/browserContext.yml @@ -273,6 +273,7 @@ BrowserContext: literals: - console - dialog + - dialogClosed - request - response - requestFinished @@ -369,6 +370,10 @@ BrowserContext: parameters: dialog: Dialog + dialogClosed: + parameters: + dialog: Dialog + page: parameters: page: Page diff --git a/packages/protocol/spec/page.yml b/packages/protocol/spec/page.yml index 30a1c94422bee..d4d41e5c1fecb 100644 --- a/packages/protocol/spec/page.yml +++ b/packages/protocol/spec/page.yml @@ -603,6 +603,7 @@ Page: literals: - console - dialog + - dialogClosed - fileChooser - request - response diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 4d09a0d987641..4a197f0d469a6 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -670,6 +670,9 @@ scheme.BrowserContextCloseEvent = tOptional(tObject({})); scheme.BrowserContextDialogEvent = tObject({ dialog: tChannel(['Dialog']), }); +scheme.BrowserContextDialogClosedEvent = tObject({ + dialog: tChannel(['Dialog']), +}); scheme.BrowserContextPageEvent = tObject({ page: tChannel(['Page']), }); @@ -870,7 +873,7 @@ scheme.BrowserContextCreateTempFilesResult = tObject({ writableStreams: tArray(tChannel(['WritableStream'])), }); scheme.BrowserContextUpdateSubscriptionParams = tObject({ - event: tEnum(['console', 'dialog', 'request', 'response', 'requestFinished', 'requestFailed']), + event: tEnum(['console', 'dialog', 'dialogClosed', 'request', 'response', 'requestFinished', 'requestFailed']), enabled: tBoolean, }); scheme.BrowserContextUpdateSubscriptionResult = tOptional(tObject({})); @@ -2592,7 +2595,7 @@ scheme.PageScreencastFrameAckResult = tOptional(tObject({})); scheme.PageScreencastStopParams = tOptional(tObject({})); scheme.PageScreencastStopResult = tOptional(tObject({})); scheme.PageUpdateSubscriptionParams = tObject({ - event: tEnum(['console', 'dialog', 'fileChooser', 'request', 'response', 'requestFinished', 'requestFailed']), + event: tEnum(['console', 'dialog', 'dialogClosed', 'fileChooser', 'request', 'response', 'requestFinished', 'requestFailed']), enabled: tBoolean, }); scheme.PageUpdateSubscriptionResult = tOptional(tObject({})); diff --git a/tests/library/browsercontext-events.spec.ts b/tests/library/browsercontext-events.spec.ts index 9afc08def24d2..e616e4a3899fb 100644 --- a/tests/library/browsercontext-events.spec.ts +++ b/tests/library/browsercontext-events.spec.ts @@ -100,6 +100,19 @@ test('dialog event should work @smoke', async ({ page }) => { expect(await promise).toBe('hello'); }); +test('dialogclosed event should work', async ({ page }) => { + const promise = page.evaluate(() => prompt('hey?')); + const dialog = await page.context().waitForEvent('dialog'); + const [closed1, closed2] = await Promise.all([ + page.context().waitForEvent('dialogclosed'), + page.waitForEvent('dialogclosed'), + dialog.accept('hello'), + ]); + expect(closed1).toBe(dialog); + expect(closed2).toBe(dialog); + expect(await promise).toBe('hello'); +}); + test('dialog event should work in popup', async ({ page }) => { const promise = page.evaluate(() => { const win = window.open(''); diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 3c0b2f0b1c4f0..16732fc5fd531 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -746,3 +746,17 @@ test('should capture console.log from ServiceWorker start', async ({ context, pa expect(consoleMessage.text()).toBe('Hello from the first line of sw.js'); expect(consoleMessage.type()).toBe('log'); }); + +test('should fire dialogclosed event when dialog is closed out of band', async ({ page }) => { + // Establish the CDP session up front: creating one while a dialog is blocking the page hangs. + const client = await page.context().newCDPSession(page); + await client.send('Page.enable'); + const dialogPromise = page.waitForEvent('dialog'); + const closedPromise = page.waitForEvent('dialogclosed'); + const evaluatePromise = page.evaluate(() => alert('yo')); + const dialog = await dialogPromise; + // Handle the dialog out of band, similar to the user closing it in the headed browser. + await client.send('Page.handleJavaScriptDialog', { accept: true }); + expect(await closedPromise).toBe(dialog); + await evaluatePromise; +}); diff --git a/tests/page/page-dialog.spec.ts b/tests/page/page-dialog.spec.ts index 657653a577b74..441611ab20115 100644 --- a/tests/page/page-dialog.spec.ts +++ b/tests/page/page-dialog.spec.ts @@ -17,6 +17,8 @@ import { test as it, expect } from './pageTest'; +import type { Dialog } from 'playwright-core'; + it('should fire', async ({ page, server }) => { page.on('dialog', dialog => { expect(dialog.type()).toBe('alert'); @@ -27,6 +29,39 @@ it('should fire', async ({ page, server }) => { await page.evaluate(() => alert('yo')); }); +it('should fire dialogclosed when dialog is accepted', async ({ page }) => { + const closed: Dialog[] = []; + page.on('dialogclosed', dialog => closed.push(dialog)); + let opened: Dialog | undefined; + page.on('dialog', dialog => { + opened = dialog; + void dialog.accept(); + }); + await page.evaluate(() => alert('yo')); + await expect.poll(() => closed.length).toBe(1); + expect(closed[0]).toBe(opened); + // Perform some roundtrips to ensure the event does not fire twice. + await page.evaluate(() => 1); + await page.evaluate(() => 1); + expect(closed.length).toBe(1); +}); + +it('should fire dialogclosed when dialog is dismissed', async ({ page }) => { + const closedPromise = page.waitForEvent('dialogclosed'); + page.on('dialog', dialog => void dialog.dismiss()); + await page.evaluate(() => confirm('boolean?')); + const dialog = await closedPromise; + expect(dialog.type()).toBe('confirm'); + expect(dialog.message()).toBe('boolean?'); +}); + +it('should fire dialogclosed for auto-dismissed dialogs', async ({ page }) => { + const closedPromise = page.waitForEvent('dialogclosed'); + await page.evaluate(() => alert('yo')); + const dialog = await closedPromise; + expect(dialog.message()).toBe('yo'); +}); + it('should allow accepting prompts @smoke', async ({ page, isElectron }) => { it.skip(isElectron, 'prompt() is not a thing in electron');