From 93dfdbf1236076154b8cfae39d2cb5bd97be5922 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 10 Jun 2025 13:57:49 +0200 Subject: [PATCH 01/71] chore(typo): if you intend (#36259) --- docs/src/test-api/class-test.md | 2 +- packages/playwright/types/test.d.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 2511f412d2871..d976058aef18d 100644 --- a/docs/src/test-api/class-test.md +++ b/docs/src/test-api/class-test.md @@ -1425,7 +1425,7 @@ Timeout in milliseconds. Skip a test. Playwright will not run the test past the `test.skip()` call. -Skipped tests are not supposed to be ever run. If you intent to fix the test, use [`method: Test.fixme`] instead. +Skipped tests are not supposed to be ever run. If you intend to fix the test, use [`method: Test.fixme`] instead. To declare a skipped test: * `test.skip(title, body)` diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 6c7db86f617a6..0f63922e2eeba 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -4124,7 +4124,7 @@ export interface TestType { /** * Skip a test. Playwright will not run the test past the `test.skip()` call. * - * Skipped tests are not supposed to be ever run. If you intent to fix the test, use + * Skipped tests are not supposed to be ever run. If you intend to fix the test, use * [test.fixme([title, details, body, condition, callback, description])](https://playwright.dev/docs/api/class-test#test-fixme) * instead. * @@ -4205,7 +4205,7 @@ export interface TestType { /** * Skip a test. Playwright will not run the test past the `test.skip()` call. * - * Skipped tests are not supposed to be ever run. If you intent to fix the test, use + * Skipped tests are not supposed to be ever run. If you intend to fix the test, use * [test.fixme([title, details, body, condition, callback, description])](https://playwright.dev/docs/api/class-test#test-fixme) * instead. * @@ -4286,7 +4286,7 @@ export interface TestType { /** * Skip a test. Playwright will not run the test past the `test.skip()` call. * - * Skipped tests are not supposed to be ever run. If you intent to fix the test, use + * Skipped tests are not supposed to be ever run. If you intend to fix the test, use * [test.fixme([title, details, body, condition, callback, description])](https://playwright.dev/docs/api/class-test#test-fixme) * instead. * @@ -4367,7 +4367,7 @@ export interface TestType { /** * Skip a test. Playwright will not run the test past the `test.skip()` call. * - * Skipped tests are not supposed to be ever run. If you intent to fix the test, use + * Skipped tests are not supposed to be ever run. If you intend to fix the test, use * [test.fixme([title, details, body, condition, callback, description])](https://playwright.dev/docs/api/class-test#test-fixme) * instead. * @@ -4448,7 +4448,7 @@ export interface TestType { /** * Skip a test. Playwright will not run the test past the `test.skip()` call. * - * Skipped tests are not supposed to be ever run. If you intent to fix the test, use + * Skipped tests are not supposed to be ever run. If you intend to fix the test, use * [test.fixme([title, details, body, condition, callback, description])](https://playwright.dev/docs/api/class-test#test-fixme) * instead. * From 92994a8f8539ef12a9315b3eeb51ee70ffc0422d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 10 Jun 2025 13:44:45 +0100 Subject: [PATCH 02/71] fix: restore proper class name escaping (#36258) --- packages/injected/src/selectorGenerator.ts | 23 ++++++++++++++++++++-- tests/library/selector-generator.spec.ts | 5 +++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/injected/src/selectorGenerator.ts b/packages/injected/src/selectorGenerator.ts index 8410d7dc7d6c1..a7dad6644fb0b 100644 --- a/packages/injected/src/selectorGenerator.ts +++ b/packages/injected/src/selectorGenerator.ts @@ -578,6 +578,25 @@ function escapeNodeName(node: Node): string { } function escapeClassName(className: string): string { - // We are escaping it for document.querySelectorAll, not for usage in CSS file. - return className.replace(/[:\.]/g, char => '\\' + char); + // We are escaping class names for document.querySelectorAll by following CSS.escape() rules. + let result = ''; + for (let i = 0; i < className.length; i++) + result += cssEscapeCharacter(className, i); + return result; +} + +function cssEscapeCharacter(s: string, i: number): string { + // https://drafts.csswg.org/cssom/#serialize-an-identifier + const c = s.charCodeAt(i); + if (c === 0x0000) + return '\uFFFD'; + if ((c >= 0x0001 && c <= 0x001f) || + (c >= 0x0030 && c <= 0x0039 && (i === 0 || (i === 1 && s.charCodeAt(0) === 0x002d)))) + return '\\' + c.toString(16) + ' '; + if (i === 0 && c === 0x002d && s.length === 1) + return '\\' + s.charAt(i); + if (c >= 0x0080 || c === 0x002d || c === 0x005f || (c >= 0x0030 && c <= 0x0039) || + (c >= 0x0041 && c <= 0x005a) || (c >= 0x0061 && c <= 0x007a)) + return s.charAt(i); + return '\\' + s.charAt(i); } diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index 68f0d2fccf28d..a78fb15a5c7d4 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -311,13 +311,14 @@ it.describe('selector generator', () => { - +
`); - expect(await generate(page, 'c[mark="1"]')).toBe('.foo.bar\\.baz > c'); + await page.$eval('[mark="1"]', c => c.parentElement.className = 'foo 12.bar.baz[&x]-_?"\''); + expect(await generate(page, 'c[mark="1"]')).toBe(`.foo.\\31 2\\.bar\\.baz\\[\\&x\\]-_\\?\\"\\' > c`); }); it('should properly join child selectors under nested ordinals', async ({ page }) => { From 3cb987f3838ce950088a4b1ad5b4e4dd39620a87 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 10 Jun 2025 15:12:42 +0200 Subject: [PATCH 03/71] fix(html-reporter): race condition where form submission used stale filterText state (#36260) --- packages/html-reporter/src/headerView.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/html-reporter/src/headerView.tsx b/packages/html-reporter/src/headerView.tsx index bea84bcdd0e43..bd4e178b55139 100644 --- a/packages/html-reporter/src/headerView.tsx +++ b/packages/html-reporter/src/headerView.tsx @@ -60,13 +60,16 @@ export const GlobalFilterView: React.FC<{ event => { event.preventDefault(); const url = new URL(window.location.href); - url.hash = filterText ? '?' + new URLSearchParams({ q: filterText }) : ''; + // If
onSubmit happens immediately after onChange, the filterText state is not updated yet. + // Using FormData here is a workaround to get the latest value. + const q = new FormData(event.target as HTMLFormElement).get('q') as string; + url.hash = q ? '?' + new URLSearchParams({ q }) : ''; navigate(url); } }> {icons.search()} {/* Use navigationId to reset defaultValue */} - { + { setFilterText(e.target.value); }}>
From a8f9c4d02a2d015d78aee73eb6e42506433a7d71 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 10 Jun 2025 16:31:44 +0100 Subject: [PATCH 04/71] test: unflake a few tests on Android (#36262) --- tests/page/page-add-init-script.spec.ts | 5 +++-- tests/page/page-network-sizes.spec.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/page/page-add-init-script.spec.ts b/tests/page/page-add-init-script.spec.ts index 86b9a83c84118..4694b026b445b 100644 --- a/tests/page/page-add-init-script.spec.ts +++ b/tests/page/page-add-init-script.spec.ts @@ -99,9 +99,10 @@ it('init script should run only once in iframe', async ({ page, server, browserN ]); }); -it('init script should not observe playwright internals', async ({ server, page, trace }) => { +it('init script should not observe playwright internals', async ({ server, page, trace, isAndroid }) => { it.skip(!!process.env.PW_CLOCK, 'clock installs globalThis.__pwClock'); - it.fixme(trace === 'on', 'tracing installs __playwright_snapshot_streamer'); + it.skip(trace === 'on', 'tracing installs __playwright_snapshot_streamer'); + it.fixme(isAndroid, 'There is probably context reuse between this test and some other test that installs a binding'); await page.addInitScript(() => { window['check'] = () => { diff --git a/tests/page/page-network-sizes.spec.ts b/tests/page/page-network-sizes.spec.ts index d63d58e79f0a6..3157b4dffb03e 100644 --- a/tests/page/page-network-sizes.spec.ts +++ b/tests/page/page-network-sizes.spec.ts @@ -41,7 +41,7 @@ it('should set bodySize to 0 if there was no body', async ({ page, server, brows ]); const sizes = await request.sizes(); expect(sizes.requestBodySize).toBe(0); - expect(sizes.requestHeadersSize).toBeGreaterThanOrEqual(200); + expect(sizes.requestHeadersSize).toBeGreaterThanOrEqual(190); }); it('should set bodySize, headersSize, and transferSize', async ({ page, server }) => { From d86787dba40f8ab6bee064f7d628a11d8b45dcae Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 10 Jun 2025 19:30:46 +0200 Subject: [PATCH 05/71] chore: roll stable-test-runner to 1.53.0-beta-1749049851000 (#36201) --- tests/playwright-test/reporter-html.spec.ts | 2 + .../stable-test-runner/package-lock.json | 46 +++++++++---------- .../stable-test-runner/package.json | 2 +- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 18b7f54fe8764..b939327f492af 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1875,6 +1875,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { const smokeLabelButton = page.locator('.test-file-test', { has: page.getByText('@smoke fails', { exact: true }) }).locator('.label', { hasText: 'smoke' }); await smokeLabelButton.click(); await expect(page).toHaveURL(/@smoke/); + await expect(searchInput).toHaveValue('@smoke '); await searchInput.clear(); await page.keyboard.press('Enter'); await expect(searchInput).toHaveValue(''); @@ -1883,6 +1884,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { const regressionLabelButton = page.locator('.test-file-test', { has: page.getByText('@regression passes', { exact: true }) }).locator('.label', { hasText: 'regression' }); await regressionLabelButton.click(); await expect(page).toHaveURL(/@regression/); + await expect(searchInput).toHaveValue('@regression '); await searchInput.clear(); await page.keyboard.press('Enter'); await expect(searchInput).toHaveValue(''); diff --git a/tests/playwright-test/stable-test-runner/package-lock.json b/tests/playwright-test/stable-test-runner/package-lock.json index 6a5222908a995..9b1f11a9971e5 100644 --- a/tests/playwright-test/stable-test-runner/package-lock.json +++ b/tests/playwright-test/stable-test-runner/package-lock.json @@ -5,16 +5,16 @@ "packages": { "": { "dependencies": { - "@playwright/test": "1.52.0-beta-1744901303000" + "@playwright/test": "1.53.0-beta-1749049851000" } }, "node_modules/@playwright/test": { - "version": "1.52.0-beta-1744901303000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-beta-1744901303000.tgz", - "integrity": "sha512-NBwE5CrFx+ErvYXlgbVsAaPGydJsjf1pRKgP+Ny3KUo/jfxQnjMBWTRwlgcaZ2Bs75tmhkN3EVvf02zEJP+zkQ==", + "version": "1.53.0-beta-1749049851000", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-beta-1749049851000.tgz", + "integrity": "sha512-6K7D8HLpEOp1LH8NfaM8b6gwen6feLzbXwudn5me5Ev1rGFnh3SzoVLLQK7TPKL62KudXuJyih32oveihNb7Fw==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.52.0-beta-1744901303000" + "playwright": "1.53.0-beta-1749049851000" }, "bin": { "playwright": "cli.js" @@ -38,12 +38,12 @@ } }, "node_modules/playwright": { - "version": "1.52.0-beta-1744901303000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-beta-1744901303000.tgz", - "integrity": "sha512-hXUDErCs2dDSpnXKOMUX7V78N6dRUVF58z7QqBO/412LmUSfuW6ZcO4qxgTpb5DLvkD/JtBoXT0bYyBGOOi7Tw==", + "version": "1.53.0-beta-1749049851000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-beta-1749049851000.tgz", + "integrity": "sha512-zpxtcU6XuiKWG8XwqSjT1c4k8X3VAZF7wHZvuf/9waPWhMe+LftPvS9ohTtIQPFZf4q/CNYdUTu7xs4I1dSrDA==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.52.0-beta-1744901303000" + "playwright-core": "1.53.0-beta-1749049851000" }, "bin": { "playwright": "cli.js" @@ -56,9 +56,9 @@ } }, "node_modules/playwright-core": { - "version": "1.52.0-beta-1744901303000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-beta-1744901303000.tgz", - "integrity": "sha512-/51OCq4NYPcWQaRthDAdZB6G3dzjfaetNs9XsLRz230Irz28rQpLeJgOifAtjbdmhtAy1yw0GAZHbAeKQYWKbQ==", + "version": "1.53.0-beta-1749049851000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-beta-1749049851000.tgz", + "integrity": "sha512-hURzp8CoEIwjoDVnsikEaZLhiH91FovOONFu4CjMNCbh47uW7mFW5jR+2Aoju0+M3YQ4XtbdHgIo+42+U3dfSA==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -70,11 +70,11 @@ }, "dependencies": { "@playwright/test": { - "version": "1.52.0-beta-1744901303000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-beta-1744901303000.tgz", - "integrity": "sha512-NBwE5CrFx+ErvYXlgbVsAaPGydJsjf1pRKgP+Ny3KUo/jfxQnjMBWTRwlgcaZ2Bs75tmhkN3EVvf02zEJP+zkQ==", + "version": "1.53.0-beta-1749049851000", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-beta-1749049851000.tgz", + "integrity": "sha512-6K7D8HLpEOp1LH8NfaM8b6gwen6feLzbXwudn5me5Ev1rGFnh3SzoVLLQK7TPKL62KudXuJyih32oveihNb7Fw==", "requires": { - "playwright": "1.52.0-beta-1744901303000" + "playwright": "1.53.0-beta-1749049851000" } }, "fsevents": { @@ -84,18 +84,18 @@ "optional": true }, "playwright": { - "version": "1.52.0-beta-1744901303000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-beta-1744901303000.tgz", - "integrity": "sha512-hXUDErCs2dDSpnXKOMUX7V78N6dRUVF58z7QqBO/412LmUSfuW6ZcO4qxgTpb5DLvkD/JtBoXT0bYyBGOOi7Tw==", + "version": "1.53.0-beta-1749049851000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-beta-1749049851000.tgz", + "integrity": "sha512-zpxtcU6XuiKWG8XwqSjT1c4k8X3VAZF7wHZvuf/9waPWhMe+LftPvS9ohTtIQPFZf4q/CNYdUTu7xs4I1dSrDA==", "requires": { "fsevents": "2.3.2", - "playwright-core": "1.52.0-beta-1744901303000" + "playwright-core": "1.53.0-beta-1749049851000" } }, "playwright-core": { - "version": "1.52.0-beta-1744901303000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-beta-1744901303000.tgz", - "integrity": "sha512-/51OCq4NYPcWQaRthDAdZB6G3dzjfaetNs9XsLRz230Irz28rQpLeJgOifAtjbdmhtAy1yw0GAZHbAeKQYWKbQ==" + "version": "1.53.0-beta-1749049851000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-beta-1749049851000.tgz", + "integrity": "sha512-hURzp8CoEIwjoDVnsikEaZLhiH91FovOONFu4CjMNCbh47uW7mFW5jR+2Aoju0+M3YQ4XtbdHgIo+42+U3dfSA==" } } } diff --git a/tests/playwright-test/stable-test-runner/package.json b/tests/playwright-test/stable-test-runner/package.json index eed3e68ce5f63..a1d7a2c2bdc7d 100644 --- a/tests/playwright-test/stable-test-runner/package.json +++ b/tests/playwright-test/stable-test-runner/package.json @@ -1,6 +1,6 @@ { "private": true, "dependencies": { - "@playwright/test": "1.52.0-beta-1744901303000" + "@playwright/test": "1.53.0-beta-1749049851000" } } From c396674f041c306cf1ed6d6adcb52e414f3c048f Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 10 Jun 2025 10:59:59 -0700 Subject: [PATCH 06/71] test: add cookie with SameSite attribute (#36255) --- .../browsercontext-add-cookies.spec.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/library/browsercontext-add-cookies.spec.ts b/tests/library/browsercontext-add-cookies.spec.ts index 6bf74c615071a..bf3a1a4d90d2f 100644 --- a/tests/library/browsercontext-add-cookies.spec.ts +++ b/tests/library/browsercontext-add-cookies.spec.ts @@ -65,6 +65,72 @@ it('should add cookies with empty value', async ({ context, page, server }) => { expect(await page.evaluate(() => document.cookie)).toEqual('marker='); }); +it('should set cookies with SameSite attribute and no secure attribute', async ({ context, browserName, isWindows, isLinux, defaultSameSiteCookieValue }) => { + // Use domain instead of URL to ensure that the `secure` attribute is not set. + await context.addCookies([{ + domain: 'foo.com', + path: '/', + name: 'same-site-unset', + value: '1', + }, { + domain: 'foo.com', + path: '/', + name: 'same-site-none', + value: '1', + sameSite: 'None', + }, { + domain: 'foo.com', + path: '/', + name: 'same-site-lax', + value: '1', + sameSite: 'Lax', + }, { + domain: 'foo.com', + path: '/', + name: 'same-site-strict', + value: '1', + sameSite: 'Strict', + }]); + const cookies = new Set(await context.cookies(['https://foo.com'])); + expect(cookies).toEqual(new Set([{ + name: 'same-site-unset', + value: '1', + domain: 'foo.com', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + sameSite: defaultSameSiteCookieValue, + }, ...(browserName === 'chromium' || (browserName === 'webkit' && isLinux) ? [] : [{ + name: 'same-site-none', + value: '1', + domain: 'foo.com', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + sameSite: 'None', + }]), { + name: 'same-site-lax', + value: '1', + domain: 'foo.com', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + sameSite: (browserName === 'webkit' && isWindows) ? 'None' : 'Lax', + }, { + name: 'same-site-strict', + value: '1', + domain: 'foo.com', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + sameSite: (browserName === 'webkit' && isWindows) ? 'None' : 'Strict', + }])); +}); + it('should roundtrip cookie', async ({ context, page, server }) => { await page.goto(server.EMPTY_PAGE); // @see https://en.wikipedia.org/wiki/Year_2038_problem From c3c842c77b3df9b62a2107ebbc76311ea6318e0e Mon Sep 17 00:00:00 2001 From: Simen Brekken Date: Tue, 10 Jun 2025 22:54:52 +0200 Subject: [PATCH 07/71] fix(network): Include subdomains of localhost when including cookies (#35771) --- .../playwright-core/src/server/cookieStore.ts | 4 ++-- packages/playwright-core/src/server/network.ts | 6 +++++- tests/library/global-fetch-cookie.spec.ts | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/server/cookieStore.ts b/packages/playwright-core/src/server/cookieStore.ts index 3e24425b3997a..c8839cebd9d71 100644 --- a/packages/playwright-core/src/server/cookieStore.ts +++ b/packages/playwright-core/src/server/cookieStore.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { kMaxCookieExpiresDateInSeconds } from './network'; +import { isLocalHostname, kMaxCookieExpiresDateInSeconds } from './network'; import type * as channels from '@protocol/channels'; @@ -30,7 +30,7 @@ export class Cookie { // https://datatracker.ietf.org/doc/html/rfc6265#section-5.4 matches(url: URL): boolean { - if (this._raw.secure && (url.protocol !== 'https:' && url.hostname !== 'localhost')) + if (this._raw.secure && (url.protocol !== 'https:' && !isLocalHostname(url.hostname))) return false; if (!domainMatches(url.hostname, this._raw.domain)) return false; diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index d00c8ca743241..739c109d8c48d 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -43,7 +43,7 @@ export function filterCookies(cookies: channels.NetworkCookie[], urls: string[]) continue; if (!parsedURL.pathname.startsWith(c.path)) continue; - if (parsedURL.protocol !== 'https:' && parsedURL.hostname !== 'localhost' && c.secure) + if (parsedURL.protocol !== 'https:' && !isLocalHostname(parsedURL.hostname) && c.secure) continue; return true; } @@ -51,6 +51,10 @@ export function filterCookies(cookies: channels.NetworkCookie[], urls: string[]) }); } +export function isLocalHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname.endsWith('.localhost'); +} + // Rollover to 5-digit year: // 253402300799 == Fri, 31 Dec 9999 23:59:59 +0000 (UTC) // 253402300800 == Sat, 1 Jan 1000 00:00:00 +0000 (UTC) diff --git a/tests/library/global-fetch-cookie.spec.ts b/tests/library/global-fetch-cookie.spec.ts index 967a3676dc198..848bf8ebcec67 100644 --- a/tests/library/global-fetch-cookie.spec.ts +++ b/tests/library/global-fetch-cookie.spec.ts @@ -37,7 +37,7 @@ type StorageStateType = PromiseArg it.skip(({ mode }) => mode !== 'default'); const __testHookLookup = (hostname: string): LookupAddress[] => { - if (hostname === 'localhost' || hostname.endsWith('one.com') || hostname.endsWith('two.com')) + if (hostname.endsWith('localhost') || hostname.endsWith('one.com') || hostname.endsWith('two.com')) return [{ address: '127.0.0.1', family: 4 }]; else throw new Error(`Failed to resolve hostname: ${hostname}`); @@ -140,6 +140,20 @@ it('should send secure cookie over http for localhost', async ({ request, server expect(serverRequest.headers.cookie).toBe('a=v; b=v'); }); +it('should send secure cookie over http for subdomains of localhost', async ({ request, server }) => { + server.setRoute('/setcookie.html', (req, res) => { + res.setHeader('Set-Cookie', ['a=v; secure', 'b=v']); + res.end(); + }); + const prefix = `http://a.b.localhost:${server.PORT}`; + await request.get(`${prefix}/setcookie.html`); + const [serverRequest] = await Promise.all([ + server.waitForRequest('/empty.html'), + request.get(`${prefix}/empty.html`) + ]); + expect(serverRequest.headers.cookie).toBe('a=v; b=v'); +}); + it('should send not expired cookies', async ({ request, server }) => { server.setRoute('/setcookie.html', (req, res) => { const tomorrow = new Date(); From df0e0fb88ced79a1ffa8baba1f32f2faca9be5a1 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 11 Jun 2025 14:25:57 +0100 Subject: [PATCH 08/71] chore: make sure dispatchers work with SdkObjects (#36158) --- .../src/server/android/android.ts | 1 - .../src/server/android/backendAdb.ts | 2 -- .../playwright-core/src/server/browserType.ts | 3 +- .../src/server/chromium/chromium.ts | 1 - .../src/server/chromium/crBrowser.ts | 2 +- .../src/server/chromium/crConnection.ts | 19 +++++------ .../server/dispatchers/androidDispatcher.ts | 31 +++++++++++++++-- .../src/server/dispatchers/dispatcher.ts | 34 +++++++++---------- .../server/dispatchers/jsonPipeDispatcher.ts | 6 ++-- .../dispatchers/localUtilsDispatcher.ts | 6 ++-- .../src/server/dispatchers/pageDispatcher.ts | 6 ++-- .../dispatchers/playwrightDispatcher.ts | 10 +++--- .../server/dispatchers/streamDispatcher.ts | 15 ++++++-- .../dispatchers/webSocketRouteDispatcher.ts | 6 ++-- .../dispatchers/writableStreamDispatcher.ts | 23 +++++++++---- .../src/server/electron/electron.ts | 2 +- .../src/server/instrumentation.ts | 9 +++++ .../playwright-core/src/server/playwright.ts | 4 +-- .../playwright-core/src/server/progress.ts | 3 +- 19 files changed, 113 insertions(+), 70 deletions(-) diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 70a95b720a717..b8a4c4772f428 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -59,7 +59,6 @@ export interface DeviceBackend { } export interface SocketBackend extends EventEmitter { - guid: string; write(data: Buffer): Promise; close(): void; } diff --git a/packages/playwright-core/src/server/android/backendAdb.ts b/packages/playwright-core/src/server/android/backendAdb.ts index 88b7c8896abe8..b25b5a24f36fe 100644 --- a/packages/playwright-core/src/server/android/backendAdb.ts +++ b/packages/playwright-core/src/server/android/backendAdb.ts @@ -18,7 +18,6 @@ import { EventEmitter } from 'events'; import net from 'net'; import { assert } from '../../utils/isomorphic/assert'; -import { createGuid } from '../utils/crypto'; import { debug } from '../../utilsBundle'; import type { Backend, DeviceBackend, SocketBackend } from './android'; @@ -117,7 +116,6 @@ function encodeMessage(message: string): Buffer { } class BufferedSocketWrapper extends EventEmitter implements SocketBackend { - readonly guid = createGuid(); private _socket: net.Socket; private _buffer = Buffer.from([]); private _isSocket = false; diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 8564f44081bbb..84f9cdaf12c41 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -56,6 +56,7 @@ export abstract class BrowserType extends SdkObject { super(parent, 'browser-type'); this.attribution.browserType = this; this._name = browserName; + this.logName = 'browser'; } executablePath(): string { @@ -69,7 +70,6 @@ export abstract class BrowserType extends SdkObject { async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise { options = this._validateLaunchOptions(options); const controller = new ProgressController(metadata, this); - controller.setLogName('browser'); const browser = await controller.run(progress => { const seleniumHubUrl = (options as any).__testHookSeleniumRemoteURL || process.env.SELENIUM_REMOTE_URL; if (seleniumHubUrl) @@ -82,7 +82,6 @@ export abstract class BrowserType extends SdkObject { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { timeout: number, cdpPort?: number, internalIgnoreHTTPSErrors?: boolean }): Promise { const launchOptions = this._validateLaunchOptions(options); const controller = new ProgressController(metadata, this); - controller.setLogName('browser'); const browser = await controller.run(async progress => { // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. let clientCertificatesProxy: ClientCertificatesProxy | undefined; diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index 61c4b0cc72795..5f7408c80c62f 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -64,7 +64,6 @@ export class Chromium extends BrowserType { override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray, timeout: number }) { const controller = new ProgressController(metadata, this); - controller.setLogName('browser'); return controller.run(async progress => { return await this._connectOverCDPInternal(progress, endpointURL, options); }, options.timeout); diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 0557ea11cf977..005c422812b38 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -58,7 +58,7 @@ export class CRBrowser extends Browser { static async connect(parent: SdkObject, transport: ConnectionTransport, options: BrowserOptions, devtools?: CRDevTools): Promise { // Make a copy in case we need to update `headful` property below. options = { ...options }; - const connection = new CRConnection(transport, options.protocolLogger, options.browserLogsCollector); + const connection = new CRConnection(parent, transport, options.protocolLogger, options.browserLogsCollector); const browser = new CRBrowser(parent, connection, options); browser._devtools = devtools; if (browser.isClank()) diff --git a/packages/playwright-core/src/server/chromium/crConnection.ts b/packages/playwright-core/src/server/chromium/crConnection.ts index 98c566c82abf1..0a8d3427fcc01 100644 --- a/packages/playwright-core/src/server/chromium/crConnection.ts +++ b/packages/playwright-core/src/server/chromium/crConnection.ts @@ -15,12 +15,11 @@ * limitations under the License. */ -import { EventEmitter } from 'events'; - import { assert, eventsHelper } from '../../utils'; import { debugLogger } from '../utils/debugLogger'; import { helper } from '../helper'; import { ProtocolError } from '../protocolError'; +import { SdkObject } from '../instrumentation'; import type { RegisteredListener } from '../../utils'; import type { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport'; @@ -37,7 +36,7 @@ export const ConnectionEvents = { // should ignore. export const kBrowserCloseMessageId = -9999; -export class CRConnection extends EventEmitter { +export class CRConnection extends SdkObject { private _lastId = 0; private readonly _transport: ConnectionTransport; readonly _sessions = new Map(); @@ -47,8 +46,8 @@ export class CRConnection extends EventEmitter { readonly rootSession: CRSession; _closed = false; - constructor(transport: ConnectionTransport, protocolLogger: ProtocolLogger, browserLogsCollector: RecentLogsCollector) { - super(); + constructor(parent: SdkObject, transport: ConnectionTransport, protocolLogger: ProtocolLogger, browserLogsCollector: RecentLogsCollector) { + super(parent, 'cr-connection'); this.setMaxListeners(0); this._transport = transport; this._protocolLogger = protocolLogger; @@ -101,7 +100,7 @@ export class CRConnection extends EventEmitter { type SessionEventListener = (method: string, params?: Object) => void; -export class CRSession extends EventEmitter { +export class CRSession extends SdkObject { private readonly _connection: CRConnection; private _eventListener?: SessionEventListener; private readonly _callbacks = new Map void, reject: (e: ProtocolError) => void, error: ProtocolError }>(); @@ -116,7 +115,7 @@ export class CRSession extends EventEmitter { override once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; constructor(connection: CRConnection, parentSession: CRSession | null, sessionId: string, eventListener?: SessionEventListener) { - super(); + super(connection, 'cr-session'); this.setMaxListeners(0); this._connection = connection; this._parentSession = parentSession; @@ -203,19 +202,17 @@ export class CRSession extends EventEmitter { } } -export class CDPSession extends EventEmitter { +export class CDPSession extends SdkObject { static Events = { Event: 'event', Closed: 'close', }; - readonly guid: string; private _session: CRSession; private _listeners: RegisteredListener[] = []; constructor(parentSession: CRSession, sessionId: string) { - super(); - this.guid = `cdp-session@${sessionId}`; + super(parentSession, 'cdp-session'); this._session = parentSession.createChildSession(sessionId, (method, params) => this.emit(CDPSession.Events.Event, { method, params })); this._listeners = [eventsHelper.addEventListener(parentSession, 'Target.detachedFromTarget', (event: Protocol.Target.detachedFromTargetPayload) => { if (event.sessionId === sessionId) diff --git a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts index 25398ce05f164..fb552b98095ff 100644 --- a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts @@ -17,6 +17,8 @@ import { BrowserContextDispatcher } from './browserContextDispatcher'; import { Dispatcher } from './dispatcher'; import { AndroidDevice } from '../android/android'; +import { eventsHelper } from '../utils/eventsHelper'; +import { SdkObject } from '../instrumentation'; import type { RootDispatcher } from './dispatcher'; import type { Android, SocketBackend } from '../android/android'; @@ -145,7 +147,7 @@ export class AndroidDeviceDispatcher extends Dispatcher { const socket = await this._object.open(params.command); - return { socket: new AndroidSocketDispatcher(this, socket) }; + return { socket: new AndroidSocketDispatcher(this, new SocketSdkObject(this._object, socket)) }; } async installApk(params: channels.AndroidDeviceInstallApkParams) { @@ -170,10 +172,33 @@ export class AndroidDeviceDispatcher extends Dispatcher implements channels.AndroidSocketChannel { +class SocketSdkObject extends SdkObject implements SocketBackend { + private _socket: SocketBackend; + private _eventListeners; + + constructor(parent: SdkObject, socket: SocketBackend) { + super(parent, 'socket'); + this._socket = socket; + this._eventListeners = [ + eventsHelper.addEventListener(socket, 'data', data => this.emit('data', data)), + eventsHelper.addEventListener(socket, 'close', () => this.emit('close')), + ]; + } + + async write(data: Buffer) { + await this._socket.write(data); + } + + close() { + this._socket.close(); + eventsHelper.removeEventListeners(this._eventListeners); + } +} + +export class AndroidSocketDispatcher extends Dispatcher implements channels.AndroidSocketChannel { _type_AndroidSocket = true; - constructor(scope: AndroidDeviceDispatcher, socket: SocketBackend) { + constructor(scope: AndroidDeviceDispatcher, socket: SocketSdkObject) { super(scope, socket, 'AndroidSocket', {}); this.addObjectListener('data', (data: Buffer) => this._dispatchEvent('data', { data })); this.addObjectListener('close', () => { diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index ca1db256e9ed8..cc38a398dfc43 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -21,7 +21,7 @@ import { ValidationError, createMetadataValidator, findValidator } from '../../ import { LongStandingScope, assert, monotonicTime, rewriteErrorMessage } from '../../utils'; import { isUnderTest } from '../utils/debug'; import { TargetClosedError, isTargetClosedError, serializeError } from '../errors'; -import { SdkObject } from '../instrumentation'; +import { createRootSdkObject, SdkObject } from '../instrumentation'; import { isProtocolError } from '../protocolError'; import { compressCallLog } from '../callLog'; import { methodMetainfo } from '../../utils/isomorphic/protocolMetainfo'; @@ -45,7 +45,7 @@ function maxDispatchersForBucket(gcBucket: string) { }[gcBucket] ?? 10000; } -export class Dispatcher extends EventEmitter implements channels.Channel { +export class Dispatcher extends EventEmitter implements channels.Channel { readonly connection: DispatcherConnection; // Parent is always "isScope". private _parent: ParentScopeType | undefined; @@ -162,13 +162,13 @@ export class Dispatcher; +export type DispatcherScope = Dispatcher; -export class RootDispatcher extends Dispatcher<{ guid: '' }, any, any> { +export class RootDispatcher extends Dispatcher { private _initialized = false; constructor(connection: DispatcherConnection, private readonly createPlaywright?: (scope: RootDispatcher, options: channels.RootInitializeParams) => Promise) { - super(connection, { guid: '' }, 'Root', {}); + super(connection, createRootSdkObject(), 'Root', {}); } async initialize(params: channels.RootInitializeParams): Promise { @@ -311,16 +311,16 @@ export class DispatcherConnection { validMetadata.internal = true; } - const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined; + const sdkObject = dispatcher._object; const callMetadata: CallMetadata = { id: `call@${id}`, location: validMetadata.location, title: validMetadata.title, internal: validMetadata.internal, stepId: validMetadata.stepId, - objectId: sdkObject?.guid, - pageId: sdkObject?.attribution?.page?.guid, - frameId: sdkObject?.attribution?.frame?.guid, + objectId: sdkObject.guid, + pageId: sdkObject.attribution?.page?.guid, + frameId: sdkObject.attribution?.frame?.guid, startTime: monotonicTime(), endTime: 0, type: dispatcher._type, @@ -329,7 +329,7 @@ export class DispatcherConnection { log: [], }; - if (sdkObject && params?.info?.waitId) { + if (params?.info?.waitId) { // Process logs for waitForNavigation/waitForLoadState/etc. const info = params.info; switch (info.phase) { @@ -356,7 +356,7 @@ export class DispatcherConnection { } } - await sdkObject?.instrumentation.onBeforeCall(sdkObject, callMetadata); + await sdkObject.instrumentation.onBeforeCall(sdkObject, callMetadata); const response: any = { id }; try { const result = await dispatcher._handleCommand(callMetadata, method, validParams); @@ -364,24 +364,22 @@ export class DispatcherConnection { response.result = validator(result, '', this._validatorToWireContext()); callMetadata.result = result; } catch (e) { - if (isTargetClosedError(e) && sdkObject) { + if (isTargetClosedError(e)) { const reason = closeReason(sdkObject); if (reason) rewriteErrorMessage(e, reason); } else if (isProtocolError(e)) { - if (e.type === 'closed') { - const reason = sdkObject ? closeReason(sdkObject) : undefined; - e = new TargetClosedError(reason, e.browserLogMessage()); - } else if (e.type === 'crashed') { + if (e.type === 'closed') + e = new TargetClosedError(closeReason(sdkObject), e.browserLogMessage()); + else if (e.type === 'crashed') rewriteErrorMessage(e, 'Target crashed ' + e.browserLogMessage()); - } } response.error = serializeError(e); // The command handler could have set error in the metadata, do not reset it if there was no exception. callMetadata.error = response.error; } finally { callMetadata.endTime = monotonicTime(); - await sdkObject?.instrumentation.onAfterCall(sdkObject, callMetadata); + await sdkObject.instrumentation.onAfterCall(sdkObject, callMetadata); } if (response.error) diff --git a/packages/playwright-core/src/server/dispatchers/jsonPipeDispatcher.ts b/packages/playwright-core/src/server/dispatchers/jsonPipeDispatcher.ts index 75c1c89fad2fa..522df815be1b2 100644 --- a/packages/playwright-core/src/server/dispatchers/jsonPipeDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/jsonPipeDispatcher.ts @@ -15,15 +15,15 @@ */ import { Dispatcher } from './dispatcher'; -import { createGuid } from '../utils/crypto'; +import { SdkObject } from '../instrumentation'; import type { LocalUtilsDispatcher } from './localUtilsDispatcher'; import type * as channels from '@protocol/channels'; -export class JsonPipeDispatcher extends Dispatcher<{ guid: string }, channels.JsonPipeChannel, LocalUtilsDispatcher> implements channels.JsonPipeChannel { +export class JsonPipeDispatcher extends Dispatcher implements channels.JsonPipeChannel { _type_JsonPipe = true; constructor(scope: LocalUtilsDispatcher) { - super(scope, { guid: 'jsonPipe@' + createGuid() }, 'JsonPipe', {}); + super(scope, new SdkObject(scope._object, 'jsonPipe'), 'JsonPipe', {}); } async send(params: channels.JsonPipeSendParams): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 5a3dc562df4a6..1a00d8c7f1f82 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -34,13 +34,14 @@ import type * as channels from '@protocol/channels'; import type * as http from 'http'; import type { HTTPRequestParams } from '../utils/network'; -export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel { +export class LocalUtilsDispatcher extends Dispatcher implements channels.LocalUtilsChannel { _type_LocalUtils: boolean; private _harBackends = new Map(); private _stackSessions = new Map(); constructor(scope: RootDispatcher, playwright: Playwright) { const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils'); + localUtils.logName = 'browser'; const deviceDescriptors = Object.entries(descriptors) .map(([name, descriptor]) => ({ name, descriptor })); super(scope, localUtils, 'LocalUtils', { @@ -82,8 +83,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. } async connect(params: channels.LocalUtilsConnectParams, metadata: CallMetadata): Promise { - const controller = new ProgressController(metadata, this._object as SdkObject); - controller.setLogName('browser'); + const controller = new ProgressController(metadata, this._object); return await controller.run(async progress => { const wsHeaders = { 'User-Agent': getUserAgent(), diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 365a3239977fb..b824297c88ff2 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -25,7 +25,7 @@ import { RequestDispatcher } from './networkDispatchers'; import { ResponseDispatcher } from './networkDispatchers'; import { RouteDispatcher, WebSocketDispatcher } from './networkDispatchers'; import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher'; -import { createGuid } from '../utils/crypto'; +import { SdkObject } from '../instrumentation'; import { urlMatches } from '../../utils/isomorphic/urlMatch'; import type { Artifact } from '../artifact'; @@ -403,7 +403,7 @@ export class WorkerDispatcher extends Dispatcher implements channels.BindingCallChannel { +export class BindingCallDispatcher extends Dispatcher implements channels.BindingCallChannel { _type_BindingCall = true; private _resolve: ((arg: any) => void) | undefined; private _reject: ((error: any) => void) | undefined; @@ -411,7 +411,7 @@ export class BindingCallDispatcher extends Dispatcher<{ guid: string }, channels constructor(scope: PageDispatcher, name: string, needsHandle: boolean, source: { context: BrowserContext, page: Page, frame: Frame }, args: any[]) { const frameDispatcher = FrameDispatcher.from(scope.parentScope(), source.frame); - super(scope, { guid: 'bindingCall@' + createGuid() }, 'BindingCall', { + super(scope, new SdkObject(scope._object, 'bindingCall'), 'BindingCall', { frame: frameDispatcher, name, args: needsHandle ? undefined : args.map(serializeResult), diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index f87f1ca85b471..63e2ba997fe35 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -24,7 +24,7 @@ import { Dispatcher } from './dispatcher'; import { ElectronDispatcher } from './electronDispatcher'; import { LocalUtilsDispatcher } from './localUtilsDispatcher'; import { APIRequestContextDispatcher } from './networkDispatchers'; -import { createGuid } from '../utils/crypto'; +import { SdkObject } from '../instrumentation'; import { eventsHelper } from '../utils/eventsHelper'; import type { RootDispatcher } from './dispatcher'; @@ -62,7 +62,7 @@ export class PlaywrightDispatcher extends Dispatcher implements channels.SocksSupportChannel { +class SocksSupportDispatcher extends Dispatcher implements channels.SocksSupportChannel { _type_SocksSupport: boolean; private _socksProxy: SocksProxy; private _socksListeners: RegisteredListener[]; - constructor(scope: RootDispatcher, socksProxy: SocksProxy) { - super(scope, { guid: 'socksSupport@' + createGuid() }, 'SocksSupport', {}); + constructor(scope: RootDispatcher, parent: SdkObject, socksProxy: SocksProxy) { + super(scope, new SdkObject(parent, 'socksSupport'), 'SocksSupport', {}); this._type_SocksSupport = true; this._socksProxy = socksProxy; this._socksListeners = [ diff --git a/packages/playwright-core/src/server/dispatchers/streamDispatcher.ts b/packages/playwright-core/src/server/dispatchers/streamDispatcher.ts index f3a0a413b7e77..82b5319c5983d 100644 --- a/packages/playwright-core/src/server/dispatchers/streamDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/streamDispatcher.ts @@ -16,18 +16,27 @@ import { Dispatcher } from './dispatcher'; import { ManualPromise } from '../../utils/isomorphic/manualPromise'; -import { createGuid } from '../utils/crypto'; +import { SdkObject } from '../instrumentation'; import type { ArtifactDispatcher } from './artifactDispatcher'; import type * as channels from '@protocol/channels'; import type * as stream from 'stream'; -export class StreamDispatcher extends Dispatcher<{ guid: string, stream: stream.Readable }, channels.StreamChannel, ArtifactDispatcher> implements channels.StreamChannel { +class StreamSdkObject extends SdkObject { + readonly stream: stream.Readable; + + constructor(parent: SdkObject, stream: stream.Readable) { + super(parent, 'stream'); + this.stream = stream; + } +} + +export class StreamDispatcher extends Dispatcher implements channels.StreamChannel { _type_Stream = true; private _ended: boolean = false; constructor(scope: ArtifactDispatcher, stream: stream.Readable) { - super(scope, { guid: 'stream@' + createGuid(), stream }, 'Stream', {}); + super(scope, new StreamSdkObject(scope._object, stream), 'Stream', {}); // In Node v12.9.0+ we can use readableEnded. stream.once('end', () => this._ended = true); stream.once('error', () => this._ended = true); diff --git a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts index 9bb05849ae9a8..975f2d2082eff 100644 --- a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts @@ -18,7 +18,7 @@ import { Page } from '../page'; import { Dispatcher } from './dispatcher'; import { PageDispatcher } from './pageDispatcher'; import * as rawWebSocketMockSource from '../../generated/webSocketMockSource'; -import { createGuid } from '../utils/crypto'; +import { SdkObject } from '../instrumentation'; import { urlMatches } from '../../utils/isomorphic/urlMatch'; import { eventsHelper } from '../utils/eventsHelper'; @@ -29,14 +29,14 @@ import type { Frame } from '../frames'; import type * as ws from '@injected/webSocketMock'; import type * as channels from '@protocol/channels'; -export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, channels.WebSocketRouteChannel, PageDispatcher | BrowserContextDispatcher> implements channels.WebSocketRouteChannel { +export class WebSocketRouteDispatcher extends Dispatcher implements channels.WebSocketRouteChannel { _type_WebSocketRoute = true; private _id: string; private _frame: Frame; private static _idToDispatcher = new Map(); constructor(scope: PageDispatcher | BrowserContextDispatcher, id: string, url: string, frame: Frame) { - super(scope, { guid: 'webSocketRoute@' + createGuid() }, 'WebSocketRoute', { url }); + super(scope, new SdkObject(scope._object, 'webSocketRoute'), 'WebSocketRoute', { url }); this._id = id; this._frame = frame; this._eventListeners.push( diff --git a/packages/playwright-core/src/server/dispatchers/writableStreamDispatcher.ts b/packages/playwright-core/src/server/dispatchers/writableStreamDispatcher.ts index 7bd6f1fd745ce..1595cae757854 100644 --- a/packages/playwright-core/src/server/dispatchers/writableStreamDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/writableStreamDispatcher.ts @@ -17,18 +17,27 @@ import fs from 'fs'; import { Dispatcher } from './dispatcher'; -import { createGuid } from '../utils/crypto'; +import { SdkObject } from '../instrumentation'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type * as channels from '@protocol/channels'; -export class WritableStreamDispatcher extends Dispatcher<{ guid: string, streamOrDirectory: fs.WriteStream | string }, channels.WritableStreamChannel, BrowserContextDispatcher> implements channels.WritableStreamChannel { +class WritableStreamSdkObject extends SdkObject { + readonly streamOrDirectory: fs.WriteStream | string; + readonly lastModifiedMs: number | undefined; + + constructor(parent: SdkObject, streamOrDirectory: fs.WriteStream | string, lastModifiedMs: number | undefined) { + super(parent, 'stream'); + this.streamOrDirectory = streamOrDirectory; + this.lastModifiedMs = lastModifiedMs; + } +} + +export class WritableStreamDispatcher extends Dispatcher implements channels.WritableStreamChannel { _type_WritableStream = true; - private _lastModifiedMs: number | undefined; constructor(scope: BrowserContextDispatcher, streamOrDirectory: fs.WriteStream | string, lastModifiedMs?: number) { - super(scope, { guid: 'writableStream@' + createGuid(), streamOrDirectory }, 'WritableStream', {}); - this._lastModifiedMs = lastModifiedMs; + super(scope, new WritableStreamSdkObject(scope._object, streamOrDirectory, lastModifiedMs), 'WritableStream', {}); } async write(params: channels.WritableStreamWriteParams): Promise { @@ -50,8 +59,8 @@ export class WritableStreamDispatcher extends Dispatcher<{ guid: string, streamO throw new Error('Cannot close a directory'); const stream = this._object.streamOrDirectory; await new Promise(fulfill => stream.end(fulfill)); - if (this._lastModifiedMs) - await fs.promises.utimes(this.path(), new Date(this._lastModifiedMs), new Date(this._lastModifiedMs)); + if (this._object.lastModifiedMs) + await fs.promises.utimes(this.path(), new Date(this._object.lastModifiedMs), new Date(this._object.lastModifiedMs)); } path(): string { diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 1602ca8c79af5..b0c154effa1ad 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -250,7 +250,7 @@ export class Electron extends SdkObject { const nodeMatch = await nodeMatchPromise; const nodeTransport = await WebSocketTransport.connect(progress, nodeMatch[1]); - const nodeConnection = new CRConnection(nodeTransport, helper.debugProtocolLogger(), browserLogsCollector); + const nodeConnection = new CRConnection(this, nodeTransport, helper.debugProtocolLogger(), browserLogsCollector); // Immediately release exiting process under debug. debuggerDisconnectPromise.then(() => { diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index 076008a31e374..9dc29950c86fc 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -29,6 +29,7 @@ import type { Page } from './page'; import type { Playwright } from './playwright'; import type { CallMetadata } from '@protocol/callMetadata'; export type { CallMetadata } from '@protocol/callMetadata'; +import type { LogName } from './utils/debugLogger'; export type Attribution = { playwright: Playwright; @@ -43,6 +44,7 @@ export class SdkObject extends EventEmitter { guid: string; attribution: Attribution; instrumentation: Instrumentation; + logName?: LogName; constructor(parent: SdkObject, guidPrefix?: string, guid?: string) { super(); @@ -53,6 +55,13 @@ export class SdkObject extends EventEmitter { } } +export function createRootSdkObject() { + const fakeParent = { attribution: {}, instrumentation: createInstrumentation() }; + const root = new SdkObject(fakeParent as any); + root.guid = ''; + return root; +} + export interface Instrumentation { addListener(listener: InstrumentationListener, context: BrowserContext | APIRequestContext | null): void; removeListener(listener: InstrumentationListener): void; diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index 28fe83d248e79..26273a258dddf 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -23,7 +23,7 @@ import { Chromium } from './chromium/chromium'; import { DebugController } from './debugController'; import { Electron } from './electron/electron'; import { Firefox } from './firefox/firefox'; -import { SdkObject, createInstrumentation } from './instrumentation'; +import { SdkObject, createRootSdkObject } from './instrumentation'; import { WebKit } from './webkit/webkit'; import type { BrowserType } from './browserType'; @@ -53,7 +53,7 @@ export class Playwright extends SdkObject { private _allBrowsers = new Set(); constructor(options: PlaywrightOptions) { - super({ attribution: {}, instrumentation: createInstrumentation() } as any, undefined, 'Playwright'); + super(createRootSdkObject(), undefined, 'Playwright'); this.options = options; this.attribution.playwright = this; this.instrumentation.addListener({ diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index 16dcc6fb1f501..68476ca21dd7a 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -36,7 +36,7 @@ export class ProgressController { // Cleanups to be run only in the case of abort. private _cleanups: (() => any)[] = []; - private _logName = 'api'; + private _logName: LogName; private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before'; private _deadline: number = 0; private _timeout: number = 0; @@ -48,6 +48,7 @@ export class ProgressController { this.metadata = metadata; this.sdkObject = sdkObject; this.instrumentation = sdkObject.instrumentation; + this._logName = sdkObject.logName || 'api'; this._forceAbortPromise.catch(e => null); // Prevent unhandled promise rejection. } From d101492713e9e191d7378b2e6244a560e7b9dabe Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 11 Jun 2025 09:25:22 -0700 Subject: [PATCH 09/71] fix(tests): lookup localhost subdomains on Win and Mac (#36285) --- tests/library/global-fetch-cookie.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/library/global-fetch-cookie.spec.ts b/tests/library/global-fetch-cookie.spec.ts index 848bf8ebcec67..f747a5cd3b334 100644 --- a/tests/library/global-fetch-cookie.spec.ts +++ b/tests/library/global-fetch-cookie.spec.ts @@ -146,7 +146,7 @@ it('should send secure cookie over http for subdomains of localhost', async ({ r res.end(); }); const prefix = `http://a.b.localhost:${server.PORT}`; - await request.get(`${prefix}/setcookie.html`); + await request.get(`${prefix}/setcookie.html`, { __testHookLookup } as any); const [serverRequest] = await Promise.all([ server.waitForRequest('/empty.html'), request.get(`${prefix}/empty.html`) From 5d0d573718fad674623e185a1fa43524fe37020a Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 11 Jun 2025 17:34:22 +0100 Subject: [PATCH 10/71] chore: remove PW_TEST_DISABLE_TRACING and _playwrightInstance (#36282) --- .../playwright-core/src/client/playwright.ts | 1 - packages/playwright/src/worker/testTracing.ts | 3 --- .../playwright-test/playwright.trace.spec.ts | 20 +------------------ 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index cc58a7b9e52b7..fac9a671ed172 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -65,7 +65,6 @@ export class Playwright extends ChannelOwner { this.devices = this._connection.localUtils()?.devices ?? {}; this.selectors = new Selectors(this._connection._platform); this.errors = { TimeoutError }; - (global as any)._playwrightInstance = this; } static from(channel: channels.PlaywrightChannel): Playwright { diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index b9294415b0584..3d43274710eab 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -68,9 +68,6 @@ export class TestTracing { } private _shouldCaptureTrace() { - if (process.env.PW_TEST_DISABLE_TRACING) - return false; - if (this._options?.mode === 'on') return true; diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index f945076393848..08d49c00e70bd 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -382,24 +382,6 @@ test('should respect --trace', async ({ runInlineTest }, testInfo) => { expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy(); }); -test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInfo) => { - const result = await runInlineTest({ - 'playwright.config.ts': ` - export default { use: { trace: 'on' } }; - `, - 'a.spec.ts': ` - import { test, expect } from '@playwright/test'; - test('test 1', async ({ page }) => { - await page.goto('about:blank'); - }); - `, - }, {}, { PW_TEST_DISABLE_TRACING: '1' }); - - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBe(false); -}); - for (const mode of ['off', 'retain-on-failure', 'on-first-retry', 'on-all-retries', 'retain-on-first-failure']) { test(`trace:${mode} should not create trace zip artifact if page test passed`, async ({ runInlineTest }) => { const result = await runInlineTest({ @@ -1010,7 +992,7 @@ test('should not produce an action entry for calling a binding', async ({ runInl wasCalled = true; return 'foo'; }); - + const output = await page.evaluate(() => window['customBinding']()); expect(wasCalled).toBe(true); expect(output).toBe('foo'); From 31865746e0d07991b273df9b2469c5f93b19a8bf Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 11 Jun 2025 17:34:31 +0100 Subject: [PATCH 11/71] chore: remove PLAYWRIGHT_SKIP_NAVIGATION_CHECK (#36283) --- packages/playwright-core/src/server/page.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 01392f7b1b219..66947dfb496e2 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -455,8 +455,6 @@ export class Page extends SdkObject { } private async _performWaitForNavigationCheck(progress: Progress) { - if (process.env.PLAYWRIGHT_SKIP_NAVIGATION_CHECK) - return; const mainFrame = this.frameManager.mainFrame(); if (!mainFrame || !mainFrame.pendingDocument()) return; From 0c5d3f2cda25fdbc6df594a7a571457a73a4975a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 11 Jun 2025 10:07:45 -0700 Subject: [PATCH 12/71] test: send secure cookies to subdomain.localhost (#36268) --- tests/library/browsercontext-proxy.spec.ts | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/library/browsercontext-proxy.spec.ts b/tests/library/browsercontext-proxy.spec.ts index 58bda44632b19..fcdae718239c0 100644 --- a/tests/library/browsercontext-proxy.spec.ts +++ b/tests/library/browsercontext-proxy.spec.ts @@ -64,6 +64,41 @@ it('should use proxy', async ({ contextFactory, server, proxyServer }) => { await context.close(); }); +it('should send secure cookies to subdomain.localhost', async ({ contextFactory, browserName, server, proxyServer }) => { + proxyServer.forwardTo(server.PORT); + const context = await contextFactory({ + proxy: { server: `localhost:${proxyServer.PORT}` }, + }); + server.setRoute('/set-cookie.html', async (req, res) => { + res.setHeader('Set-Cookie', [`non-secure=1; HttpOnly`, `secure=1; HttpOnly; Secure`]); + res.end(); + }); + server.setRoute('/read-cookie.html', async (req, res) => { + res.setHeader('Content-Type', `text/html`); + res.end(`
Cookie: ${req.headers.cookie.split(';').map(c => c.trim()).sort().join('; ')}
`); + }); + + const page = await context.newPage(); + + await page.goto(`http://subdomain.localhost/set-cookie.html`); + + const cookies = await context.cookies('http://subdomain.localhost'); + expect(cookies.map(({ name, domain }) => ({ name, domain }))).toEqual([ + { + name: 'non-secure', + domain: 'subdomain.localhost', + }, + ...(browserName === 'webkit' ? [] : [{ + name: 'secure', + domain: 'subdomain.localhost', + }]), + ]); + + await page.goto(`http://subdomain.localhost/read-cookie.html`); + await expect(page.locator('div')).toHaveText(browserName === 'webkit' ? 'Cookie: non-secure=1' : 'Cookie: non-secure=1; secure=1'); + + await context.close(); +}); it('should set cookie for top-level domain', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/18362' } From 64dd5ca5693775b2d1ee5c6e0aeb3e4dc8f03d64 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Thu, 12 Jun 2025 08:50:04 +0200 Subject: [PATCH 13/71] feat(firefox-beta): roll to r1483 (#36289) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index e7a5eaf4c0c2e..13b443f1c45aa 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -33,9 +33,9 @@ }, { "name": "firefox-beta", - "revision": "1482", + "revision": "1483", "installByDefault": false, - "browserVersion": "138.0b10" + "browserVersion": "140.0b7" }, { "name": "webkit", From 3c248ed163ff760eb064777130cfe9eb244a8323 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 12 Jun 2025 09:46:04 +0100 Subject: [PATCH 14/71] chore: remove PLAYWRIGHT_INPUT_FILE_TEXTBOX (#36281) --- packages/injected/src/ariaSnapshot.ts | 4 ++-- packages/injected/src/domUtils.ts | 1 - packages/injected/src/injectedScript.ts | 3 +-- packages/injected/src/roleUtils.ts | 6 +++--- packages/playwright-core/src/server/dom.ts | 1 - packages/trace-viewer/src/ui/snapshotTab.tsx | 4 ++-- 6 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index a75a5cd185d6c..c1f2ab4bdc411 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -16,7 +16,7 @@ import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils'; -import { box, getElementComputedStyle, getGlobalOptions, isElementVisible } from './domUtils'; +import { box, getElementComputedStyle, isElementVisible } from './domUtils'; import * as roleUtils from './roleUtils'; import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml'; @@ -214,7 +214,7 @@ function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: s result.selected = roleUtils.getAriaSelected(element); if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { - if (element.type !== 'checkbox' && element.type !== 'radio' && (element.type !== 'file' || getGlobalOptions().inputFileRoleTextbox)) + if (element.type !== 'checkbox' && element.type !== 'radio' && element.type !== 'file') result.children = [element.value]; } diff --git a/packages/injected/src/domUtils.ts b/packages/injected/src/domUtils.ts index eb97e04a8b322..e11d6476949cb 100644 --- a/packages/injected/src/domUtils.ts +++ b/packages/injected/src/domUtils.ts @@ -16,7 +16,6 @@ type GlobalOptions = { browserNameForWorkarounds?: string; - inputFileRoleTextbox?: boolean; }; let globalOptions: GlobalOptions = {}; export function setGlobalOptions(options: GlobalOptions) { diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 05a1c9eb97f88..5155204e2b1b0 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -73,7 +73,6 @@ export type InjectedScriptOptions = { testIdAttributeName: string; stableRafCount: number; browserName: string; - inputFileRoleTextbox: boolean; customEngines: { name: string, source: string }[]; }; @@ -236,7 +235,7 @@ export class InjectedScript { this._stableRafCount = options.stableRafCount; this._browserName = options.browserName; - setGlobalOptions({ browserNameForWorkarounds: options.browserName, inputFileRoleTextbox: options.inputFileRoleTextbox }); + setGlobalOptions({ browserNameForWorkarounds: options.browserName }); this._setupGlobalListenersRemovalDetection(); this._setupHitTargetInterceptors(); diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index b9bdd333a01c8..139b26bb6394b 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -16,7 +16,7 @@ import * as css from '@isomorphic/cssTokenizer'; -import { getGlobalOptions, closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils'; +import { closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils'; import type { AriaRole } from '@isomorphic/ariaSnapshot'; @@ -135,7 +135,7 @@ const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => AriaRole | nu // File inputs do not have a role by the spec: https://www.w3.org/TR/html-aam-1.0/#el-input-file. // However, there are open issues about fixing it: https://github.com/w3c/aria/issues/1926. // All browsers report it as a button, and it is rendered as a button, so we do "button". - if (type === 'file' && !getGlobalOptions().inputFileRoleTextbox) + if (type === 'file') return 'button'; return inputTypeToRole[type] || 'textbox'; }, @@ -707,7 +707,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt // There is no spec for this, but Chromium/WebKit do "Choose File" so we follow that. // All browsers respect labels, aria-labelledby and aria-label. // No browsers respect the title attribute, although w3c accname tests disagree. We follow browsers. - if (!getGlobalOptions().inputFileRoleTextbox && tagName === 'INPUT' && (element as HTMLInputElement).type === 'file') { + if (tagName === 'INPUT' && (element as HTMLInputElement).type === 'file') { options.visitedElements.add(element); const labels = (element as HTMLInputElement).labels || []; if (labels.length && !options.embeddedInLabelledBy) diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index ed7cb37cef660..c2f422b864791 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -94,7 +94,6 @@ export class FrameExecutionContext extends js.ExecutionContext { testIdAttributeName: selectorsRegistry.testIdAttributeName(), stableRafCount: this.frame._page.delegate.rafCountForStablePosition(), browserName: this.frame._page.browserContext._browser.options.name, - inputFileRoleTextbox: process.env.PLAYWRIGHT_INPUT_FILE_TEXTBOX ? true : false, customEngines, }; const source = ` diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 77c25164b9aff..bfef4840d6739 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -77,7 +77,7 @@ export const SnapshotTabsView: React.FunctionComponent<{ { const win = window.open(snapshotUrls?.popoutUrl || '', '_blank'); win?.addEventListener('DOMContentLoaded', () => { - const injectedScript = new InjectedScript(win as any, { isUnderTest, sdkLanguage, testIdAttributeName, stableRafCount: 1, browserName: 'chromium', inputFileRoleTextbox: false, customEngines: [] }); + const injectedScript = new InjectedScript(win as any, { isUnderTest, sdkLanguage, testIdAttributeName, stableRafCount: 1, browserName: 'chromium', customEngines: [] }); injectedScript.consoleApi.install(); }); }} /> @@ -284,7 +284,7 @@ function createRecorders(recorders: { recorder: Recorder, frameSelector: string return; const win = frameWindow as any; if (!win._recorder && force) { - const injectedScript = new InjectedScript(frameWindow as any, { isUnderTest, sdkLanguage, testIdAttributeName, stableRafCount: 1, browserName: 'chromium', inputFileRoleTextbox: false, customEngines: [] }); + const injectedScript = new InjectedScript(frameWindow as any, { isUnderTest, sdkLanguage, testIdAttributeName, stableRafCount: 1, browserName: 'chromium', customEngines: [] }); const recorder = new Recorder(injectedScript); win._injectedScript = injectedScript; win._recorder = { recorder, frameSelector: parentFrameSelector }; From 33d87d93dd8c08fd4dfa3964c007d08717f98553 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 12 Jun 2025 10:12:42 +0100 Subject: [PATCH 15/71] chore: accept Progress instance for raw input (#36280) --- .../src/server/bidi/bidiInput.ts | 36 ++-- .../src/server/bidi/bidiPage.ts | 2 +- .../src/server/browserContext.ts | 22 ++- .../src/server/chromium/crDragDrop.ts | 11 +- .../src/server/chromium/crInput.ts | 33 ++-- .../src/server/chromium/crPage.ts | 4 +- .../src/server/dispatchers/pageDispatcher.ts | 29 +-- packages/playwright-core/src/server/dom.ts | 20 +- .../src/server/firefox/ffInput.ts | 25 ++- .../src/server/firefox/ffPage.ts | 4 +- packages/playwright-core/src/server/frames.ts | 14 +- packages/playwright-core/src/server/input.ts | 176 ++++++++++++------ .../playwright-core/src/server/launchApp.ts | 2 +- packages/playwright-core/src/server/page.ts | 12 +- .../src/server/recorder/recorderRunner.ts | 2 +- .../src/server/webkit/wkInput.ts | 26 ++- .../src/server/webkit/wkPage.ts | 2 +- 17 files changed, 261 insertions(+), 159 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiInput.ts b/packages/playwright-core/src/server/bidi/bidiInput.ts index e67c07ba8ff64..5e9875f3a2e62 100644 --- a/packages/playwright-core/src/server/bidi/bidiInput.ts +++ b/packages/playwright-core/src/server/bidi/bidiInput.ts @@ -21,6 +21,7 @@ import * as bidi from './third_party/bidiProtocol'; import type * as input from '../input'; import type * as types from '../types'; import type { BidiSession } from './bidiConnection'; +import type { Progress } from '../progress'; export class RawKeyboardImpl implements input.RawKeyboard { private _session: BidiSession; @@ -33,31 +34,31 @@ export class RawKeyboardImpl implements input.RawKeyboard { this._session = session; } - async keydown(modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { + async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { keyName = resolveSmartModifierString(keyName); const actions: bidi.Input.KeySourceAction[] = []; actions.push({ type: 'keyDown', value: getBidiKeyValue(keyName) }); - await this._performActions(actions); + await this._performActions(progress, actions); } - async keyup(modifiers: Set, keyName: string, description: input.KeyDescription): Promise { + async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { keyName = resolveSmartModifierString(keyName); const actions: bidi.Input.KeySourceAction[] = []; actions.push({ type: 'keyUp', value: getBidiKeyValue(keyName) }); - await this._performActions(actions); + await this._performActions(progress, actions); } - async sendText(text: string): Promise { + async sendText(progress: Progress, text: string): Promise { const actions: bidi.Input.KeySourceAction[] = []; for (const char of text) { const value = getBidiKeyValue(char); actions.push({ type: 'keyDown', value }); actions.push({ type: 'keyUp', value }); } - await this._performActions(actions); + await this._performActions(progress, actions); } - private async _performActions(actions: bidi.Input.KeySourceAction[]) { + private async _performActions(progress: Progress, actions: bidi.Input.KeySourceAction[]) { await this._session.send('input.performActions', { context: this._session.sessionId, actions: [ @@ -68,6 +69,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { } ] }); + progress.throwIfAborted(); } } @@ -78,19 +80,19 @@ export class RawMouseImpl implements input.RawMouse { this._session = session; } - async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - await this._performActions([{ type: 'pointerMove', x, y }]); + async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { + await this._performActions(progress, [{ type: 'pointerMove', x, y }]); } - async down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - await this._performActions([{ type: 'pointerDown', button: toBidiButton(button) }]); + async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._performActions(progress, [{ type: 'pointerDown', button: toBidiButton(button) }]); } - async up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - await this._performActions([{ type: 'pointerUp', button: toBidiButton(button) }]); + async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + await this._performActions(progress, [{ type: 'pointerUp', button: toBidiButton(button) }]); } - async wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { // Bidi throws when x/y are not integers. x = Math.floor(x); y = Math.floor(y); @@ -104,9 +106,10 @@ export class RawMouseImpl implements input.RawMouse { } ] }); + progress.throwIfAborted(); } - private async _performActions(actions: bidi.Input.PointerSourceAction[]) { + private async _performActions(progress: Progress, actions: bidi.Input.PointerSourceAction[]) { await this._session.send('input.performActions', { context: this._session.sessionId, actions: [ @@ -120,6 +123,7 @@ export class RawMouseImpl implements input.RawMouse { } ] }); + progress.throwIfAborted(); } } @@ -130,7 +134,7 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { this._session = session; } - async tap(x: number, y: number, modifiers: Set) { + async tap(progress: Progress, x: number, y: number, modifiers: Set) { } } diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index f8d379d179034..eacbe3e88f6d3 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -510,7 +510,7 @@ export class BidiPage implements PageDelegate { async inputActionEpilogue(): Promise { } - async resetForReuse(): Promise { + async resetForReuse(progress: Progress): Promise { } async pdf(options: channels.PagePdfParams): Promise { diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 5d9857d61ae83..cd49380705acc 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -36,13 +36,14 @@ import { RecorderApp } from './recorder/recorderApp'; import { Selectors } from './selectors'; import { Tracing } from './trace/recorder/tracing'; import * as rawStorageSource from '../generated/storageScriptSource'; +import { ProgressController } from './progress'; import type { Artifact } from './artifact'; import type { Browser, BrowserOptions } from './browser'; import type { Download } from './download'; import type * as frames from './frames'; import type { CallMetadata } from './instrumentation'; -import type { Progress, ProgressController } from './progress'; +import type { Progress } from './progress'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import type { SerializedStorage } from '@injected/storageScript'; import type * as types from './types'; @@ -191,6 +192,11 @@ export abstract class BrowserContext extends SdkObject { } async resetForReuse(metadata: CallMetadata, params: channels.BrowserNewContextForReuseParams | null) { + const controller = new ProgressController(metadata, this); + return controller.run(progress => this.resetForReuseImpl(progress, params)); + } + + async resetForReuseImpl(progress: Progress, params: channels.BrowserNewContextForReuseParams | null) { await this.tracing.resetForReuse(); if (params) { @@ -204,14 +210,14 @@ export abstract class BrowserContext extends SdkObject { let page: Page | undefined = this.pages()[0]; const [, ...otherPages] = this.pages(); for (const p of otherPages) - await p.close(metadata); + await p.close(); if (page && page.hasCrashed()) { - await page.close(metadata); + await page.close(); page = undefined; } // Navigate to about:blank first to ensure no page scripts are running after this point. - await page?.mainFrame().goto(metadata, 'about:blank', { timeout: 0 }); + await page?.mainFrame().gotoImpl(progress, 'about:blank', {}); await this._resetStorage(); await this.clock.resetForReuse(); @@ -227,7 +233,7 @@ export abstract class BrowserContext extends SdkObject { await this.clearCache(); await this._resetCookies(); - await page?.resetForReuse(metadata); + await page?.resetForReuse(progress); } _browserClosed() { @@ -398,7 +404,7 @@ export abstract class BrowserContext extends SdkObject { // - chromium fails to change isMobile for existing page; // - webkit fails to change locale for existing page. await this.newPage(progress.metadata); - await defaultPage.close(progress.metadata); + await defaultPage.close(); } } @@ -564,7 +570,7 @@ export abstract class BrowserContext extends SdkObject { if (storage.localStorage.length || storage.indexedDB?.length) result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); } - await page.close(internalMetadata); + await page.close(); } return result; } @@ -632,7 +638,7 @@ export abstract class BrowserContext extends SdkObject { })()`; await frame.evaluateExpression(restoreScript, { world: 'utility' }); } - await page.close(internalMetadata); + await page.close(); } } finally { this._settingStorageState = false; diff --git a/packages/playwright-core/src/server/chromium/crDragDrop.ts b/packages/playwright-core/src/server/chromium/crDragDrop.ts index f94a30670110d..d10c36d370d1b 100644 --- a/packages/playwright-core/src/server/chromium/crDragDrop.ts +++ b/packages/playwright-core/src/server/chromium/crDragDrop.ts @@ -19,6 +19,7 @@ import { assert } from '../../utils'; import type { CRPage } from './crPage'; import type * as types from '../types'; import type { Protocol } from './protocol'; +import type { Progress } from '../progress'; declare global { @@ -51,7 +52,7 @@ export class DragManager { return true; } - async interceptDragCausedByMove(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, moveCallback: () => Promise): Promise { + async interceptDragCausedByMove(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, moveCallback: () => Promise): Promise { this._lastPosition = { x, y }; if (this._dragState) { await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { @@ -61,6 +62,7 @@ export class DragManager { data: this._dragState, modifiers: toModifiersMask(modifiers), }); + progress.throwIfAborted(); return; } if (button !== 'left') @@ -91,6 +93,7 @@ export class DragManager { } await this._crPage._page.safeNonStallingEvaluateInAllFrames(`(${setupDragListeners.toString()})()`, 'utility'); + progress.cleanupWhenAborted(() => this._crPage._page.safeNonStallingEvaluateInAllFrames('window.__cleanupDrag && window.__cleanupDrag()', 'utility')); client.on('Input.dragIntercepted', onDragIntercepted!); try { @@ -109,7 +112,7 @@ export class DragManager { this._dragState = expectingDrag ? (await dragInterceptedPromise).data : null; client.off('Input.dragIntercepted', onDragIntercepted!); await client.send('Input.setInterceptDrags', { enabled: false }); - + progress.throwIfAborted(); if (this._dragState) { await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { @@ -119,6 +122,7 @@ export class DragManager { data: this._dragState, modifiers: toModifiersMask(modifiers), }); + progress.throwIfAborted(); } } @@ -126,7 +130,7 @@ export class DragManager { return !!this._dragState; } - async drop(x: number, y: number, modifiers: Set) { + async drop(progress: Progress, x: number, y: number, modifiers: Set) { assert(this._dragState, 'missing drag state'); await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { type: 'drop', @@ -136,5 +140,6 @@ export class DragManager { modifiers: toModifiersMask(modifiers), }); this._dragState = null; + progress.throwIfAborted(); } } diff --git a/packages/playwright-core/src/server/chromium/crInput.ts b/packages/playwright-core/src/server/chromium/crInput.ts index 2ec52432fbc3e..8e045fc5c7c57 100644 --- a/packages/playwright-core/src/server/chromium/crInput.ts +++ b/packages/playwright-core/src/server/chromium/crInput.ts @@ -24,6 +24,7 @@ import type * as types from '../types'; import type { CRSession } from './crConnection'; import type { DragManager } from './crDragDrop'; import type { CRPage } from './crPage'; +import type { Progress } from '../progress'; export class RawKeyboardImpl implements input.RawKeyboard { @@ -52,10 +53,11 @@ export class RawKeyboardImpl implements input.RawKeyboard { return commands.map(c => c.substring(0, c.length - 1)); } - async keydown(modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { + async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { const { code, key, location, text } = description; if (code === 'Escape' && await this._dragManger.cancelDrag()) return; + progress.throwIfAborted(); const commands = this._commandsForCode(code, modifiers); await this._client.send('Input.dispatchKeyEvent', { type: text ? 'keyDown' : 'rawKeyDown', @@ -70,9 +72,10 @@ export class RawKeyboardImpl implements input.RawKeyboard { location, isKeypad: location === input.keypadLocation }); + progress.throwIfAborted(); } - async keyup(modifiers: Set, keyName: string, description: input.KeyDescription): Promise { + async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { const { code, key, location } = description; await this._client.send('Input.dispatchKeyEvent', { type: 'keyUp', @@ -82,10 +85,12 @@ export class RawKeyboardImpl implements input.RawKeyboard { code, location }); + progress.throwIfAborted(); } - async sendText(text: string): Promise { + async sendText(progress: Progress, text: string): Promise { await this._client.send('Input.insertText', { text }); + progress.throwIfAborted(); } } @@ -100,7 +105,7 @@ export class RawMouseImpl implements input.RawMouse { this._dragManager = dragManager; } - async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { + async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { const actualMove = async () => { await this._client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', @@ -115,12 +120,14 @@ export class RawMouseImpl implements input.RawMouse { if (forClick) { // Avoid extra protocol calls related to drag and drop, because click relies on // move-down-up protocol commands being sent synchronously. - return actualMove(); + await actualMove(); + progress.throwIfAborted(); + return; } - await this._dragManager.interceptDragCausedByMove(x, y, button, buttons, modifiers, actualMove); + await this._dragManager.interceptDragCausedByMove(progress, x, y, button, buttons, modifiers, actualMove); } - async down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { if (this._dragManager.isDragging()) return; await this._client.send('Input.dispatchMouseEvent', { @@ -133,11 +140,12 @@ export class RawMouseImpl implements input.RawMouse { clickCount, force: buttons.size > 0 ? 0.5 : 0, }); + progress.throwIfAborted(); } - async up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { if (this._dragManager.isDragging()) { - await this._dragManager.drop(x, y, modifiers); + await this._dragManager.drop(progress, x, y, modifiers); return; } await this._client.send('Input.dispatchMouseEvent', { @@ -149,9 +157,10 @@ export class RawMouseImpl implements input.RawMouse { modifiers: toModifiersMask(modifiers), clickCount }); + progress.throwIfAborted(); } - async wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { await this._client.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, @@ -160,6 +169,7 @@ export class RawMouseImpl implements input.RawMouse { deltaX, deltaY, }); + progress.throwIfAborted(); } } @@ -169,7 +179,7 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { constructor(client: CRSession) { this._client = client; } - async tap(x: number, y: number, modifiers: Set) { + async tap(progress: Progress, x: number, y: number, modifiers: Set) { await Promise.all([ this._client.send('Input.dispatchTouchEvent', { type: 'touchStart', @@ -184,5 +194,6 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { touchPoints: [] }), ]); + progress.throwIfAborted(); } } diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 6a9f4d6613981..0edd05aa47d9e 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -339,9 +339,9 @@ export class CRPage implements PageDelegate { await this._mainFrameSession._client.send('Page.enable').catch(e => {}); } - async resetForReuse(): Promise { + async resetForReuse(progress: Progress): Promise { // See https://github.com/microsoft/playwright/issues/22432. - await this.rawMouse.move(-1, -1, 'none', new Set(), new Set(), true); + await this.rawMouse.move(progress, -1, -1, 'none', new Set(), new Set(), true); } async pdf(options: channels.PagePdfParams): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index b824297c88ff2..4d633c7f2cfa3 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -238,7 +238,7 @@ export class PageDispatcher extends Dispatcher { if (!params.runBeforeUnload) metadata.potentiallyClosesScope = true; - await this._page.close(metadata, params); + await this._page.close(params); } async updateSubscription(params: channels.PageUpdateSubscriptionParams): Promise { @@ -251,47 +251,52 @@ export class PageDispatcher extends Dispatcher { - await this._page.keyboard.down(params.key); + await this._page.keyboard.down(metadata, params.key); } async keyboardUp(params: channels.PageKeyboardUpParams, metadata: CallMetadata): Promise { - await this._page.keyboard.up(params.key); + await this._page.keyboard.up(metadata, params.key); } async keyboardInsertText(params: channels.PageKeyboardInsertTextParams, metadata: CallMetadata): Promise { - await this._page.keyboard.insertText(params.text); + await this._page.keyboard.insertText(metadata, params.text); } async keyboardType(params: channels.PageKeyboardTypeParams, metadata: CallMetadata): Promise { - await this._page.keyboard.type(params.text, params); + await this._page.keyboard.type(metadata, params.text, params); } async keyboardPress(params: channels.PageKeyboardPressParams, metadata: CallMetadata): Promise { - await this._page.keyboard.press(params.key, params); + await this._page.keyboard.press(metadata, params.key, params); } async mouseMove(params: channels.PageMouseMoveParams, metadata: CallMetadata): Promise { - await this._page.mouse.move(params.x, params.y, params, metadata); + metadata.point = { x: params.x, y: params.y }; + await this._page.mouse.move(metadata, params.x, params.y, params); } async mouseDown(params: channels.PageMouseDownParams, metadata: CallMetadata): Promise { - await this._page.mouse.down(params, metadata); + metadata.point = this._page.mouse.currentPoint(); + await this._page.mouse.down(metadata, params); } async mouseUp(params: channels.PageMouseUpParams, metadata: CallMetadata): Promise { - await this._page.mouse.up(params, metadata); + metadata.point = this._page.mouse.currentPoint(); + await this._page.mouse.up(metadata, params); } async mouseClick(params: channels.PageMouseClickParams, metadata: CallMetadata): Promise { - await this._page.mouse.click(params.x, params.y, params, metadata); + metadata.point = { x: params.x, y: params.y }; + await this._page.mouse.click(metadata, params.x, params.y, params); } async mouseWheel(params: channels.PageMouseWheelParams, metadata: CallMetadata): Promise { - await this._page.mouse.wheel(params.deltaX, params.deltaY); + await this._page.mouse.wheel(metadata, params.deltaX, params.deltaY); } async touchscreenTap(params: channels.PageTouchscreenTapParams, metadata: CallMetadata): Promise { - await this._page.touchscreen.tap(params.x, params.y, metadata); + metadata.point = { x: params.x, y: params.y }; + await this._page.touchscreen.tap(metadata, params.x, params.y); } async accessibilitySnapshot(params: channels.PageAccessibilitySnapshotParams, metadata: CallMetadata): Promise { diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index c2f422b864791..835c9331b6f21 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -504,11 +504,11 @@ export class ElementHandle extends js.JSHandle { progress.throwIfAborted(); // Avoid action that has side-effects. let restoreModifiers: types.KeyboardModifier[] | undefined; if (options && options.modifiers) - restoreModifiers = await this._page.keyboard.ensureModifiers(options.modifiers); + restoreModifiers = await this._page.keyboard.ensureModifiers(progress, options.modifiers); progress.log(` performing ${actionName} action`); await action(point); if (restoreModifiers) - await this._page.keyboard.ensureModifiers(restoreModifiers); + await this._page.keyboard.ensureModifiers(progress, restoreModifiers); if (hitTargetInterceptionHandle) { const stopHitTargetInterception = this._frame.raceAgainstEvaluationStallingEvents(() => { return hitTargetInterceptionHandle.evaluate(h => h.stop()); @@ -554,7 +554,7 @@ export class ElementHandle extends js.JSHandle { } _hover(progress: Progress, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { - return this._retryPointerAction(progress, 'hover', false /* waitForEnabled */, point => this._page.mouse.move(point.x, point.y), { ...options, waitAfter: 'disabled' }); + return this._retryPointerAction(progress, 'hover', false /* waitForEnabled */, point => this._page.mouse._move(progress, point.x, point.y), { ...options, waitAfter: 'disabled' }); } async click(metadata: CallMetadata, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions): Promise { @@ -567,7 +567,7 @@ export class ElementHandle extends js.JSHandle { } _click(progress: Progress, options: { waitAfter: boolean | 'disabled' } & types.MouseClickOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { - return this._retryPointerAction(progress, 'click', true /* waitForEnabled */, point => this._page.mouse.click(point.x, point.y, options), options); + return this._retryPointerAction(progress, 'click', true /* waitForEnabled */, point => this._page.mouse._click(progress, point.x, point.y, options), options); } async dblclick(metadata: CallMetadata, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions): Promise { @@ -580,7 +580,7 @@ export class ElementHandle extends js.JSHandle { } _dblclick(progress: Progress, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { - return this._retryPointerAction(progress, 'dblclick', true /* waitForEnabled */, point => this._page.mouse.dblclick(point.x, point.y, options), { ...options, waitAfter: 'disabled' }); + return this._retryPointerAction(progress, 'dblclick', true /* waitForEnabled */, point => this._page.mouse._click(progress, point.x, point.y, { ...options, clickCount: 2 }), { ...options, waitAfter: 'disabled' }); } async tap(metadata: CallMetadata, options: types.PointerActionWaitOptions): Promise { @@ -593,7 +593,7 @@ export class ElementHandle extends js.JSHandle { } _tap(progress: Progress, options: types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { - return this._retryPointerAction(progress, 'tap', true /* waitForEnabled */, point => this._page.touchscreen.tap(point.x, point.y), { ...options, waitAfter: 'disabled' }); + return this._retryPointerAction(progress, 'tap', true /* waitForEnabled */, point => this._page.touchscreen._tap(progress, point.x, point.y), { ...options, waitAfter: 'disabled' }); } async selectOption(metadata: CallMetadata, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise { @@ -658,9 +658,9 @@ export class ElementHandle extends js.JSHandle { progress.throwIfAborted(); // Avoid action that has side-effects. if (result === 'needsinput') { if (value) - await this._page.keyboard.insertText(value); + await this._page.keyboard._insertText(progress, value); else - await this._page.keyboard.press('Delete'); + await this._page.keyboard._press(progress, 'Delete'); return 'done'; } else { return result; @@ -773,7 +773,7 @@ export class ElementHandle extends js.JSHandle { if (result !== 'done') return result; progress.throwIfAborted(); // Avoid action that has side-effects. - await this._page.keyboard.type(text, options); + await this._page.keyboard._type(progress, text, options); return 'done'; } @@ -794,7 +794,7 @@ export class ElementHandle extends js.JSHandle { if (result !== 'done') return result; progress.throwIfAborted(); // Avoid action that has side-effects. - await this._page.keyboard.press(key, options); + await this._page.keyboard._press(progress, key, options); return 'done'; }); } diff --git a/packages/playwright-core/src/server/firefox/ffInput.ts b/packages/playwright-core/src/server/firefox/ffInput.ts index 42d2c71ebf671..9485c4a88116b 100644 --- a/packages/playwright-core/src/server/firefox/ffInput.ts +++ b/packages/playwright-core/src/server/firefox/ffInput.ts @@ -17,6 +17,7 @@ import type * as input from '../input'; import type { Page } from '../page'; +import type { Progress } from '../progress'; import type * as types from '../types'; import type { FFSession } from './ffConnection'; @@ -61,7 +62,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { this._client = client; } - async keydown(modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { + async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { let text = description.text; // Firefox will figure out Enter by itself if (text === '\r') @@ -76,9 +77,10 @@ export class RawKeyboardImpl implements input.RawKeyboard { location, text, }); + progress.throwIfAborted(); } - async keyup(modifiers: Set, keyName: string, description: input.KeyDescription): Promise { + async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { const { code, key, location } = description; await this._client.send('Page.dispatchKeyEvent', { type: 'keyup', @@ -88,10 +90,12 @@ export class RawKeyboardImpl implements input.RawKeyboard { location, repeat: false }); + progress.throwIfAborted(); } - async sendText(text: string): Promise { + async sendText(progress: Progress, text: string): Promise { await this._client.send('Page.insertText', { text }); + progress.throwIfAborted(); } } @@ -103,7 +107,7 @@ export class RawMouseImpl implements input.RawMouse { this._client = client; } - async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { + async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { await this._client.send('Page.dispatchMouseEvent', { type: 'mousemove', button: 0, @@ -112,9 +116,10 @@ export class RawMouseImpl implements input.RawMouse { y: Math.floor(y), modifiers: toModifiersMask(modifiers) }); + progress.throwIfAborted(); } - async down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { await this._client.send('Page.dispatchMouseEvent', { type: 'mousedown', button: toButtonNumber(button), @@ -124,9 +129,10 @@ export class RawMouseImpl implements input.RawMouse { modifiers: toModifiersMask(modifiers), clickCount }); + progress.throwIfAborted(); } - async up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { await this._client.send('Page.dispatchMouseEvent', { type: 'mouseup', button: toButtonNumber(button), @@ -136,9 +142,10 @@ export class RawMouseImpl implements input.RawMouse { modifiers: toModifiersMask(modifiers), clickCount }); + progress.throwIfAborted(); } - async wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { // Wheel events hit the compositor first, so wait one frame for it to be synced. await this._page!.mainFrame().evaluateExpression(`new Promise(requestAnimationFrame)`, { world: 'utility' }); await this._client.send('Page.dispatchWheelEvent', { @@ -149,6 +156,7 @@ export class RawMouseImpl implements input.RawMouse { deltaZ: 0, modifiers: toModifiersMask(modifiers) }); + progress.throwIfAborted(); } setPage(page: Page) { @@ -162,11 +170,12 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { constructor(client: FFSession) { this._client = client; } - async tap(x: number, y: number, modifiers: Set) { + async tap(progress: Progress, x: number, y: number, modifiers: Set) { await this._client.send('Page.dispatchTapEvent', { x, y, modifiers: toModifiersMask(modifiers), }); + progress.throwIfAborted(); } } diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index c4bf23a6ac3c8..49ddf5a5c9238 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -545,12 +545,12 @@ export class FFPage implements PageDelegate { async inputActionEpilogue(): Promise { } - async resetForReuse(): Promise { + async resetForReuse(progress: Progress): Promise { // Firefox sometimes keeps the last mouse position in the page, // which affects things like hovered state. // See https://github.com/microsoft/playwright/issues/22432. // Move mouse to (-1, -1) to avoid anything being hovered. - await this.rawMouse.move(-1, -1, 'none', new Set(), new Set(), false); + await this.rawMouse.move(progress, -1, -1, 'none', new Set(), new Set(), false); } async getFrameElement(frame: frames.Frame): Promise { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index c6f47914108a5..d173770292a3c 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -604,7 +604,7 @@ export class Frame extends SdkObject { const controller = new ProgressController(serverSideCallMetadata(), this); const data = { url, - gotoPromise: controller.run(progress => this._gotoAction(progress, url, { referer }), 0), + gotoPromise: controller.run(progress => this.gotoImpl(progress, url, { referer }), 0), }; this._redirectedNavigations.set(documentId, data); data.gotoPromise.finally(() => this._redirectedNavigations.delete(documentId)); @@ -614,11 +614,11 @@ export class Frame extends SdkObject { const constructedNavigationURL = constructURLBasedOnBaseURL(this._page.browserContext._options.baseURL, url); const controller = new ProgressController(metadata, this); return controller.run(progress => { - return this.raceNavigationAction(progress, options, async () => this._gotoAction(progress, constructedNavigationURL, options)); + return this.raceNavigationAction(progress, options, async () => this.gotoImpl(progress, constructedNavigationURL, options)); }, options.timeout); } - private async _gotoAction(progress: Progress, url: string, options: Omit): Promise { + async gotoImpl(progress: Progress, url: string, options: Omit): Promise { const waitUntil = verifyLifecycle('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); progress.log(`navigating to "${url}", waiting until "${waitUntil}"`); const headers = this._page.extraHTTPHeaders() || []; @@ -1162,8 +1162,8 @@ export class Frame extends SdkObject { await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, options.strict, !options.force /* performActionPreChecks */, async handle => { return handle._retryPointerAction(progress, 'move and down', false, async point => { - await this._page.mouse.move(point.x, point.y); - await this._page.mouse.down(); + await this._page.mouse._move(progress, point.x, point.y); + await this._page.mouse._down(progress); }, { ...options, waitAfter: 'disabled', @@ -1174,8 +1174,8 @@ export class Frame extends SdkObject { // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. dom.assertDone(await this._retryWithProgressIfNotConnected(progress, target, options.strict, false /* performActionPreChecks */, async handle => { return handle._retryPointerAction(progress, 'move and up', false, async point => { - await this._page.mouse.move(point.x, point.y); - await this._page.mouse.up(); + await this._page.mouse._move(progress, point.x, point.y); + await this._page.mouse._up(progress); }, { ...options, waitAfter: 'disabled', diff --git a/packages/playwright-core/src/server/input.ts b/packages/playwright-core/src/server/input.ts index 9c8a72fccf532..a82dc69ae8b11 100644 --- a/packages/playwright-core/src/server/input.ts +++ b/packages/playwright-core/src/server/input.ts @@ -16,8 +16,10 @@ import { assert } from '../utils'; import * as keyboardLayout from './usKeyboardLayout'; +import { ProgressController } from './progress'; -import type { CallMetadata } from './instrumentation'; +import type { CallMetadata } from '@protocol/callMetadata'; +import type { Progress } from './progress'; import type { Page } from './page'; import type * as types from './types'; @@ -36,27 +38,34 @@ export type KeyDescription = { const kModifiers: types.KeyboardModifier[] = ['Alt', 'Control', 'Meta', 'Shift']; export interface RawKeyboard { - keydown(modifiers: Set, keyName: string, description: KeyDescription, autoRepeat: boolean): Promise; - keyup(modifiers: Set, keyName: string, description: KeyDescription): Promise; - sendText(text: string): Promise; + keydown(progress: Progress, modifiers: Set, keyName: string, description: KeyDescription, autoRepeat: boolean): Promise; + keyup(progress: Progress, modifiers: Set, keyName: string, description: KeyDescription): Promise; + sendText(progress: Progress, text: string): Promise; } export class Keyboard { private _pressedModifiers = new Set(); private _pressedKeys = new Set(); private _raw: RawKeyboard; + private _page: Page; - constructor(raw: RawKeyboard) { + constructor(raw: RawKeyboard, page: Page) { this._raw = raw; + this._page = page; + } + + async down(metadata: CallMetadata, key: string) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._down(progress, key)); } - async down(key: string) { + async _down(progress: Progress, key: string) { const description = this._keyDescriptionForString(key); const autoRepeat = this._pressedKeys.has(description.code); this._pressedKeys.add(description.code); if (kModifiers.includes(description.key as types.KeyboardModifier)) this._pressedModifiers.add(description.key as types.KeyboardModifier); - await this._raw.keydown(this._pressedModifiers, key, description, autoRepeat); + await this._raw.keydown(progress, this._pressedModifiers, key, description, autoRepeat); } private _keyDescriptionForString(str: string): KeyDescription { @@ -72,32 +81,52 @@ export class Keyboard { return description; } - async up(key: string) { + async up(metadata: CallMetadata, key: string) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._up(progress, key)); + } + + async _up(progress: Progress, key: string) { const description = this._keyDescriptionForString(key); if (kModifiers.includes(description.key as types.KeyboardModifier)) this._pressedModifiers.delete(description.key as types.KeyboardModifier); this._pressedKeys.delete(description.code); - await this._raw.keyup(this._pressedModifiers, key, description); + await this._raw.keyup(progress, this._pressedModifiers, key, description); + } + + async insertText(metadata: CallMetadata, text: string) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._insertText(progress, text)); + } + + async _insertText(progress: Progress, text: string) { + await this._raw.sendText(progress, text); } - async insertText(text: string) { - await this._raw.sendText(text); + async type(metadata: CallMetadata, text: string, options?: { delay?: number }) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._type(progress, text, options)); } - async type(text: string, options?: { delay?: number }) { + async _type(progress: Progress, text: string, options?: { delay?: number }) { const delay = (options && options.delay) || undefined; for (const char of text) { if (usKeyboardLayout.has(char)) { - await this.press(char, { delay }); + await this._press(progress, char, { delay }); } else { if (delay) - await new Promise(f => setTimeout(f, delay)); - await this.insertText(char); + await wait(progress, delay); + await this._insertText(progress, char); } } } - async press(key: string, options: { delay?: number } = {}) { + async press(metadata: CallMetadata, key: string, options: { delay?: number }) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._press(progress, key, options)); + } + + async _press(progress: Progress, key: string, options: { delay?: number } = {}) { function split(keyString: string) { const keys = []; let building = ''; @@ -116,16 +145,16 @@ export class Keyboard { const tokens = split(key); key = tokens[tokens.length - 1]; for (let i = 0; i < tokens.length - 1; ++i) - await this.down(tokens[i]); - await this.down(key); + await this._down(progress, tokens[i]); + await this._down(progress, key); if (options.delay) - await new Promise(f => setTimeout(f, options.delay)); - await this.up(key); + await wait(progress, options.delay); + await this._up(progress, key); for (let i = tokens.length - 2; i >= 0; --i) - await this.up(tokens[i]); + await this._up(progress, tokens[i]); } - async ensureModifiers(mm: types.SmartKeyboardModifier[]): Promise { + async ensureModifiers(progress: Progress, mm: types.SmartKeyboardModifier[]): Promise { const modifiers = mm.map(resolveSmartModifier); for (const modifier of modifiers) { if (!kModifiers.includes(modifier)) @@ -136,9 +165,9 @@ export class Keyboard { const needDown = modifiers.includes(key); const isDown = this._pressedModifiers.has(key); if (needDown && !isDown) - await this.down(key); + await this._down(progress, key); else if (!needDown && isDown) - await this.up(key); + await this._up(progress, key); } return restore; } @@ -159,10 +188,10 @@ export function resolveSmartModifier(m: types.SmartKeyboardModifier): types.Keyb } export interface RawMouse { - move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise; - down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise; - up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise; - wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise; + move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise; + down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise; + up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise; + wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise; } export class Mouse { @@ -180,9 +209,16 @@ export class Mouse { this._keyboard = this._page.keyboard; } - async move(x: number, y: number, options: { steps?: number, forClick?: boolean } = {}, metadata?: CallMetadata) { - if (metadata) - metadata.point = { x, y }; + currentPoint() { + return { x: this._x, y: this._y }; + } + + async move(metadata: CallMetadata, x: number, y: number, options: { steps?: number, forClick?: boolean }) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._move(progress, x, y, options)); + } + + async _move(progress: Progress, x: number, y: number, options: { steps?: number, forClick?: boolean } = {}) { const { steps = 1 } = options; const fromX = this._x; const fromY = this._y; @@ -191,58 +227,66 @@ export class Mouse { for (let i = 1; i <= steps; i++) { const middleX = fromX + (x - fromX) * (i / steps); const middleY = fromY + (y - fromY) * (i / steps); - await this._raw.move(middleX, middleY, this._lastButton, this._buttons, this._keyboard._modifiers(), !!options.forClick); + await this._raw.move(progress, middleX, middleY, this._lastButton, this._buttons, this._keyboard._modifiers(), !!options.forClick); } } - async down(options: { button?: types.MouseButton, clickCount?: number } = {}, metadata?: CallMetadata) { - if (metadata) - metadata.point = { x: this._x, y: this._y }; + async down(metadata: CallMetadata, options: { button?: types.MouseButton, clickCount?: number }) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._down(progress, options)); + } + + async _down(progress: Progress, options: { button?: types.MouseButton, clickCount?: number } = {}) { const { button = 'left', clickCount = 1 } = options; this._lastButton = button; this._buttons.add(button); - await this._raw.down(this._x, this._y, this._lastButton, this._buttons, this._keyboard._modifiers(), clickCount); + await this._raw.down(progress, this._x, this._y, this._lastButton, this._buttons, this._keyboard._modifiers(), clickCount); } - async up(options: { button?: types.MouseButton, clickCount?: number } = {}, metadata?: CallMetadata) { - if (metadata) - metadata.point = { x: this._x, y: this._y }; + async up(metadata: CallMetadata, options: { button?: types.MouseButton, clickCount?: number }) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._up(progress, options)); + } + + async _up(progress: Progress, options: { button?: types.MouseButton, clickCount?: number } = {}) { const { button = 'left', clickCount = 1 } = options; this._lastButton = 'none'; this._buttons.delete(button); - await this._raw.up(this._x, this._y, button, this._buttons, this._keyboard._modifiers(), clickCount); + await this._raw.up(progress, this._x, this._y, button, this._buttons, this._keyboard._modifiers(), clickCount); + } + + async click(metadata: CallMetadata, x: number, y: number, options: { delay?: number, button?: types.MouseButton, clickCount?: number }) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._click(progress, x, y, options)); } - async click(x: number, y: number, options: { delay?: number, button?: types.MouseButton, clickCount?: number } = {}, metadata?: CallMetadata) { - if (metadata) - metadata.point = { x, y }; + async _click(progress: Progress, x: number, y: number, options: { delay?: number, button?: types.MouseButton, clickCount?: number } = {}) { const { delay = null, clickCount = 1 } = options; if (delay) { - this.move(x, y, { forClick: true }); + this._move(progress, x, y, { forClick: true }); for (let cc = 1; cc <= clickCount; ++cc) { - await this.down({ ...options, clickCount: cc }); - await new Promise(f => setTimeout(f, delay)); - await this.up({ ...options, clickCount: cc }); + await this._down(progress, { ...options, clickCount: cc }); + await wait(progress, delay); + await this._up(progress, { ...options, clickCount: cc }); if (cc < clickCount) - await new Promise(f => setTimeout(f, delay)); + await wait(progress, delay); } } else { const promises = []; - promises.push(this.move(x, y, { forClick: true })); + promises.push(this._move(progress, x, y, { forClick: true })); for (let cc = 1; cc <= clickCount; ++cc) { - promises.push(this.down({ ...options, clickCount: cc })); - promises.push(this.up({ ...options, clickCount: cc })); + promises.push(this._down(progress, { ...options, clickCount: cc })); + promises.push(this._up(progress, { ...options, clickCount: cc })); } await Promise.all(promises); } } - async dblclick(x: number, y: number, options: { delay?: number, button?: types.MouseButton } = {}) { - await this.click(x, y, { ...options, clickCount: 2 }); - } - - async wheel(deltaX: number, deltaY: number) { - await this._raw.wheel(this._x, this._y, this._buttons, this._keyboard._modifiers(), deltaX, deltaY); + async wheel(metadata: CallMetadata, deltaX: number, deltaY: number) { + const controller = new ProgressController(metadata, this._page); + return controller.run(async progress => { + await this._raw.wheel(progress, this._x, this._y, this._buttons, this._keyboard._modifiers(), deltaX, deltaY); + }); } } @@ -307,7 +351,7 @@ function buildLayoutClosure(layout: keyboardLayout.KeyboardLayout): Map): Promise; + tap(progress: Progress, x: number, y: number, modifiers: Set): Promise; } export class Touchscreen { @@ -319,11 +363,19 @@ export class Touchscreen { this._page = page; } - async tap(x: number, y: number, metadata?: CallMetadata) { - if (metadata) - metadata.point = { x, y }; + async tap(metadata: CallMetadata, x: number, y: number) { + const controller = new ProgressController(metadata, this._page); + return controller.run(progress => this._tap(progress, x, y)); + } + + async _tap(progress: Progress, x: number, y: number) { if (!this._page.browserContext._options.hasTouch) throw new Error('hasTouch must be enabled on the browser context before using the touchscreen.'); - await this._raw.tap(x, y, this._page.keyboard._modifiers()); + await this._raw.tap(progress, x, y, this._page.keyboard._modifiers()); } } + +async function wait(progress: Progress, ms: number) { + await new Promise(f => setTimeout(f, Math.min(ms, progress.timeUntilDeadline()))); + progress.throwIfAborted(); +} diff --git a/packages/playwright-core/src/server/launchApp.ts b/packages/playwright-core/src/server/launchApp.ts index 5ef69fcb876ac..3b3bcda5bf942 100644 --- a/packages/playwright-core/src/server/launchApp.ts +++ b/packages/playwright-core/src/server/launchApp.ts @@ -65,7 +65,7 @@ export async function launchApp(browserType: BrowserType, options: { context.on('page', async (newPage: Page) => { if (newPage.mainFrame().url() === 'chrome://new-tab-page/') { await page.bringToFront(); - await newPage.close(serverSideCallMetadata()); + await newPage.close(); } }); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 66947dfb496e2..b2ca066122c75 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -94,7 +94,7 @@ export interface PageDelegate { // Work around for asynchronously dispatched CSP errors in Firefox. readonly cspErrorsAsynchronousForInlineScripts?: boolean; // Work around for mouse position in Firefox. - resetForReuse(): Promise; + resetForReuse(progress: Progress): Promise; // WebKit hack. shouldToggleStyleSheetToSyncAnimations(): boolean; } @@ -180,7 +180,7 @@ export class Page extends SdkObject { this.delegate = delegate; this.browserContext = browserContext; this.accessibility = new accessibility.Accessibility(delegate.getAccessibilityTree.bind(delegate)); - this.keyboard = new input.Keyboard(delegate.rawKeyboard); + this.keyboard = new input.Keyboard(delegate.rawKeyboard, this); this.mouse = new input.Mouse(delegate.rawMouse, this); this.touchscreen = new input.Touchscreen(delegate.rawTouchscreen, this); this.screenshotter = new Screenshotter(this); @@ -254,12 +254,12 @@ export class Page extends SdkObject { this._eventsToEmitAfterInitialized.push({ event, args }); } - async resetForReuse(metadata: CallMetadata) { + async resetForReuse(progress: Progress) { this._locatorHandlers.clear(); // Re-navigate once init scripts are gone. // TODO: we should have a timeout for `resetForReuse`. - await this.mainFrame().goto(metadata, 'about:blank', { timeout: 0 }); + await this.mainFrame().gotoImpl(progress, 'about:blank', {}); this._emulatedSize = undefined; this._emulatedMedia = {}; this._extraHTTPHeaders = undefined; @@ -269,7 +269,7 @@ export class Page extends SdkObject { this.delegate.updateEmulateMedia(), ]); - await this.delegate.resetForReuse(); + await this.delegate.resetForReuse(progress); } _didClose() { @@ -695,7 +695,7 @@ export class Page extends SdkObject { options.timeout); } - async close(metadata: CallMetadata, options: { runBeforeUnload?: boolean, reason?: string } = {}) { + async close(options: { runBeforeUnload?: boolean, reason?: string } = {}) { if (this._closedState === 'closed') return; if (options.reason) diff --git a/packages/playwright-core/src/server/recorder/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts index 5936060609765..5253643e6d875 100644 --- a/packages/playwright-core/src/server/recorder/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -39,7 +39,7 @@ export async function performAction(pageAliases: Map, actionInCont throw Error('Not reached'); if (action.name === 'closePage') { - await mainFrame._page.close(callMetadata); + await mainFrame._page.close(); return; } diff --git a/packages/playwright-core/src/server/webkit/wkInput.ts b/packages/playwright-core/src/server/webkit/wkInput.ts index dd4b1f9456b55..148e96b29d82c 100644 --- a/packages/playwright-core/src/server/webkit/wkInput.ts +++ b/packages/playwright-core/src/server/webkit/wkInput.ts @@ -22,6 +22,7 @@ import { macEditingCommands } from '../macEditingCommands'; import type * as types from '../types'; import type { WKSession } from './wkConnection'; import type { Page } from '../page'; +import type { Progress } from '../progress'; function toModifiersMask(modifiers: Set): number { // From Source/WebKit/Shared/WebEvent.h @@ -60,7 +61,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { this._session = session; } - async keydown(modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { + async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { const parts = []; for (const modifier of (['Shift', 'Control', 'Alt', 'Meta']) as types.KeyboardModifier[]) { if (modifiers.has(modifier)) @@ -84,9 +85,10 @@ export class RawKeyboardImpl implements input.RawKeyboard { macCommands: commands, isKeypad: description.location === input.keypadLocation }); + progress.throwIfAborted(); } - async keyup(modifiers: Set, keyName: string, description: input.KeyDescription): Promise { + async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { const { code, key } = description; await this._pageProxySession.send('Input.dispatchKeyEvent', { type: 'keyUp', @@ -96,10 +98,12 @@ export class RawKeyboardImpl implements input.RawKeyboard { code, isKeypad: description.location === input.keypadLocation }); + progress.throwIfAborted(); } - async sendText(text: string): Promise { + async sendText(progress: Progress, text: string): Promise { await this._session!.send('Page.insertText', { text }); + progress.throwIfAborted(); } } @@ -116,7 +120,7 @@ export class RawMouseImpl implements input.RawMouse { this._session = session; } - async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { + async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { await this._pageProxySession.send('Input.dispatchMouseEvent', { type: 'move', button, @@ -125,9 +129,10 @@ export class RawMouseImpl implements input.RawMouse { y, modifiers: toModifiersMask(modifiers) }); + progress.throwIfAborted(); } - async down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { await this._pageProxySession.send('Input.dispatchMouseEvent', { type: 'down', button, @@ -137,9 +142,10 @@ export class RawMouseImpl implements input.RawMouse { modifiers: toModifiersMask(modifiers), clickCount }); + progress.throwIfAborted(); } - async up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { await this._pageProxySession.send('Input.dispatchMouseEvent', { type: 'up', button, @@ -149,14 +155,16 @@ export class RawMouseImpl implements input.RawMouse { modifiers: toModifiersMask(modifiers), clickCount }); + progress.throwIfAborted(); } - async wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { if (this._page?.browserContext._options.isMobile) throw new Error('Mouse wheel is not supported in mobile WebKit'); await this._session!.send('Page.updateScrollingState'); // Wheel events hit the compositor first, so wait one frame for it to be synced. await this._page!.mainFrame().evaluateExpression(`new Promise(requestAnimationFrame)`, { world: 'utility' }); + progress.throwIfAborted(); await this._pageProxySession.send('Input.dispatchWheelEvent', { x, y, @@ -164,6 +172,7 @@ export class RawMouseImpl implements input.RawMouse { deltaY, modifiers: toModifiersMask(modifiers), }); + progress.throwIfAborted(); } setPage(page: Page) { @@ -178,11 +187,12 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { this._pageProxySession = session; } - async tap(x: number, y: number, modifiers: Set) { + async tap(progress: Progress, x: number, y: number, modifiers: Set) { await this._pageProxySession.send('Input.dispatchTapEvent', { x, y, modifiers: toModifiersMask(modifiers), }); + progress.throwIfAborted(); } } diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 8e2844937314d..99707016843c5 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -994,7 +994,7 @@ export class WKPage implements PageDelegate { async inputActionEpilogue(): Promise { } - async resetForReuse(): Promise { + async resetForReuse(progress: Progress): Promise { } async getFrameElement(frame: frames.Frame): Promise { From 19718c0ab680713f2c96fe10365d690fe4d62325 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 12 Jun 2025 11:28:04 +0100 Subject: [PATCH 16/71] chore: fix android socket on('close') (#36293) --- .../src/server/dispatchers/androidDispatcher.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts index fb552b98095ff..cce5f325d1508 100644 --- a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts @@ -181,7 +181,10 @@ class SocketSdkObject extends SdkObject implements SocketBackend { this._socket = socket; this._eventListeners = [ eventsHelper.addEventListener(socket, 'data', data => this.emit('data', data)), - eventsHelper.addEventListener(socket, 'close', () => this.emit('close')), + eventsHelper.addEventListener(socket, 'close', () => { + eventsHelper.removeEventListeners(this._eventListeners); + this.emit('close'); + }), ]; } @@ -191,7 +194,6 @@ class SocketSdkObject extends SdkObject implements SocketBackend { close() { this._socket.close(); - eventsHelper.removeEventListeners(this._eventListeners); } } From bf3101f455b69c3cd2f2849963119baafd297555 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 12 Jun 2025 23:46:56 +0200 Subject: [PATCH 17/71] feat(chromium): add local-fonts API permission (#36186) --- docs/src/api/class-browsercontext.md | 1 + packages/playwright-client/types/types.d.ts | 1 + .../src/server/chromium/crBrowser.ts | 1 + packages/playwright-core/types/types.d.ts | 1 + tests/library/permissions.spec.ts | 16 ++++++++++++++++ 5 files changed, 20 insertions(+) diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index f9dda43e11a29..375b6a48b507a 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -987,6 +987,7 @@ Here are some permissions that may be supported by some browsers: * `'notifications'` * `'payment-handler'` * `'storage-access'` +* `'local-fonts'` ### option: BrowserContext.grantPermissions.origin * since: v1.8 diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index dbd022dcc3388..f788301765da1 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -8993,6 +8993,7 @@ export interface BrowserContext { * - `'notifications'` * - `'payment-handler'` * - `'storage-access'` + * - `'local-fonts'` * @param options */ grantPermissions(permissions: ReadonlyArray, options?: { diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 005c422812b38..0c640c9879ab9 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -455,6 +455,7 @@ export class CRBrowserContext extends BrowserContext { // chrome-specific permissions we have. ['midi-sysex', 'midiSysex'], ['storage-access', 'storageAccess'], + ['local-fonts', 'localFonts'], ]); const filtered = permissions.map(permission => { const protocolPermission = webPermissionToProtocol.get(permission); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index dbd022dcc3388..f788301765da1 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -8993,6 +8993,7 @@ export interface BrowserContext { * - `'notifications'` * - `'payment-handler'` * - `'storage-access'` + * - `'local-fonts'` * @param options */ grantPermissions(permissions: ReadonlyArray, options?: { diff --git a/tests/library/permissions.spec.ts b/tests/library/permissions.spec.ts index 0f1f5cd0f51f1..cd2ab184b19ce 100644 --- a/tests/library/permissions.spec.ts +++ b/tests/library/permissions.spec.ts @@ -236,3 +236,19 @@ it('storage access', { expect(access).toBe(true); expect(await frame.evaluate(() => document.hasStorageAccess())).toBe(true); }); + +it.describe(() => { + // Secure context + it.use({ ignoreHTTPSErrors: true, }); + + it('should be able to use the local-fonts API', async ({ page, context, httpsServer, browserName }) => { + it.skip(browserName !== 'chromium', 'chromium-only api'); + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36113' }); + + await page.goto(httpsServer.EMPTY_PAGE); + expect(await getPermission(page, 'local-fonts')).toBe('prompt'); + await context.grantPermissions(['local-fonts']); + expect(await getPermission(page, 'local-fonts')).toBe('granted'); + expect(await page.evaluate(async () => (await (window as any).queryLocalFonts()).length > 0)).toBe(true); + }); +}); From 1655ae996164e4db0fe2528a0b10183d08b26861 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Thu, 12 Jun 2025 23:49:23 +0200 Subject: [PATCH 18/71] feat(chromium): roll to r1179 (#36301) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 8 +- .../src/server/deviceDescriptorsSource.json | 108 +++++++++--------- 3 files changed, 60 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index eb576b1d38d78..b5dcab4d78cfc 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-138.0.7204.15-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-139.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-138.0.7204.23-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-139.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 138.0.7204.15 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 138.0.7204.23 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.5 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 139.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 13b443f1c45aa..4e697d0b24fa1 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1178", + "revision": "1179", "installByDefault": true, - "browserVersion": "138.0.7204.15" + "browserVersion": "138.0.7204.23" }, { "name": "chromium-headless-shell", - "revision": "1178", + "revision": "1179", "installByDefault": true, - "browserVersion": "138.0.7204.15" + "browserVersion": "138.0.7204.23" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index b595e0347d417..c42a1abf3e4b0 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 480, "height": 1040 @@ -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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 1040, "height": 480 @@ -198,7 +198,7 @@ "defaultBrowserType": "chromium" }, "Galaxy A55": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 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/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", "viewport": { "width": 1024, "height": 640 @@ -1208,7 +1208,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1219,7 +1219,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1230,7 +1230,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1241,7 +1241,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1252,7 +1252,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1263,7 +1263,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1274,7 +1274,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/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1285,7 +1285,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/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1296,7 +1296,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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1307,7 +1307,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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1318,7 +1318,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1329,7 +1329,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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1340,7 +1340,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1351,7 +1351,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1362,7 +1362,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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1373,7 +1373,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/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1384,7 +1384,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1395,7 +1395,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1406,7 +1406,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/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1417,7 +1417,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/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1472,7 +1472,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1483,7 +1483,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1494,7 +1494,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1505,7 +1505,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1516,7 +1516,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1527,7 +1527,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/138.0.7204.15 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/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1538,7 +1538,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1549,7 +1549,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1560,7 +1560,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1575,7 +1575,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1590,7 +1590,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1605,7 +1605,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1620,7 +1620,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1635,7 +1635,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1650,7 +1650,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1661,7 +1661,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1672,7 +1672,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1687,7 +1687,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Safari/537.36 Edg/138.0.7204.15", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36 Edg/138.0.7204.23", "screen": { "width": 1792, "height": 1120 @@ -1732,7 +1732,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1747,7 +1747,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.15 Safari/537.36 Edg/138.0.7204.15", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36 Edg/138.0.7204.23", "screen": { "width": 1920, "height": 1080 From 468237d1eb02df6d56804a1f2e7a2f40c0e71880 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 00:19:32 +0200 Subject: [PATCH 19/71] feat(chromium-tip-of-tree): roll to r1340 (#36306) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 4e697d0b24fa1..937aeb5fd2330 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,15 +15,15 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1338", + "revision": "1340", "installByDefault": false, - "browserVersion": "139.0.7219.3" + "browserVersion": "139.0.7234.0" }, { "name": "chromium-tip-of-tree-headless-shell", - "revision": "1338", + "revision": "1340", "installByDefault": false, - "browserVersion": "139.0.7219.3" + "browserVersion": "139.0.7234.0" }, { "name": "firefox", From 15d033f20e6685faf4ac197ad4176c52e0424793 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:37:05 +0200 Subject: [PATCH 20/71] feat(webkit): roll to r2184 (#36309) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 937aeb5fd2330..24f68d61ae241 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "2183", + "revision": "2184", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", From 0bf6c3d56b2082ea2e01411890643e7a521b1d45 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 13 Jun 2025 11:35:37 +0200 Subject: [PATCH 21/71] chore: validate launchOptions options (#36276) --- packages/playwright-core/src/DEPS.list | 2 ++ .../playwright-core/src/browserServerImpl.ts | 16 +++++++++++++++- tests/library/browsertype-launch-server.spec.ts | 4 ++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/DEPS.list b/packages/playwright-core/src/DEPS.list index d21eb103c4ad9..c4f50cb22e158 100644 --- a/packages/playwright-core/src/DEPS.list +++ b/packages/playwright-core/src/DEPS.list @@ -4,6 +4,8 @@ server/ server/utils utils/isomorphic/ utilsBundle.ts +protocol/validator.ts +protocol/validatorPrimitives.ts [androidServerImpl.ts] remote/ diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index 927bb6d68dedf..34881f133fc94 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -20,9 +20,11 @@ import { helper } from './server/helper'; import { serverSideCallMetadata } from './server/instrumentation'; import { createPlaywright } from './server/playwright'; import { createGuid } from './server/utils/crypto'; +import { isUnderTest } from './server/utils/debug'; import { rewriteErrorMessage } from './utils/isomorphic/stackTrace'; import { DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT } from './utils/isomorphic/time'; import { ws } from './utilsBundle'; +import * as validatorPrimitives from './protocol/validatorPrimitives'; import type { BrowserServer, BrowserServerLauncher } from './client/browserType'; import type { LaunchServerOptions, Logger, Env } from './client/types'; @@ -45,19 +47,31 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { // 1. Pre-launch the browser const metadata = serverSideCallMetadata(); - const launchOptions = { + const validatorContext = { + tChannelImpl: (names: '*' | string[], arg: any, path: string) => { + throw new validatorPrimitives.ValidationError(`${path}: channels are not expected in launchServer`); + }, + binary: 'buffer', + isUnderTest, + } satisfies validatorPrimitives.ValidatorContext; + let launchOptions = { ...options, ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined, ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs), env: options.env ? envObjectToArray(options.env) : undefined, timeout: options.timeout ?? DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT, }; + let browser: Browser; try { if (options._userDataDir !== undefined) { + const validator = validatorPrimitives.scheme['BrowserTypeLaunchPersistentContextParams']; + launchOptions = validator({ ...launchOptions, userDataDir: options._userDataDir }, '', validatorContext); const context = await playwright[this._browserName].launchPersistentContext(metadata, options._userDataDir, launchOptions); browser = context._browser; } else { + const validator = validatorPrimitives.scheme['BrowserTypeLaunchParams']; + launchOptions = validator(launchOptions, '', validatorContext); browser = await playwright[this._browserName].launch(metadata, launchOptions, toProtocolLogger(options.logger)); } } catch (e) { diff --git a/tests/library/browsertype-launch-server.spec.ts b/tests/library/browsertype-launch-server.spec.ts index f56d9d500e4d1..fed973dde57d1 100644 --- a/tests/library/browsertype-launch-server.spec.ts +++ b/tests/library/browsertype-launch-server.spec.ts @@ -26,6 +26,10 @@ it.describe('launch server', () => { await browserServer.close(); }); + it('should validate options', async ({ browserType }) => { + await expect(browserType.launchServer({ channel: null })).rejects.toThrow(/channel: expected string, got object/); + }); + it('should work with host', async ({ browserType }) => { const host = '0.0.0.0'; const browserServer = await browserType.launchServer({ host }); From 357ebfe6719612ce6dd974b4601fbecf13556d74 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 16 Jun 2025 10:13:31 +0100 Subject: [PATCH 22/71] chore: make input actions "strict" in terms of timeout/abort (#36302) --- .../src/server/bidi/bidiInput.ts | 15 +- .../src/server/browserContext.ts | 3 +- .../src/server/chromium/crDragDrop.ts | 18 +- .../src/server/chromium/crInput.ts | 41 ++--- packages/playwright-core/src/server/dom.ts | 171 +++++++++--------- .../src/server/firefox/ffInput.ts | 38 ++-- packages/playwright-core/src/server/frames.ts | 152 ++++++++-------- packages/playwright-core/src/server/helper.ts | 5 +- packages/playwright-core/src/server/input.ts | 35 ++-- packages/playwright-core/src/server/page.ts | 11 +- .../playwright-core/src/server/progress.ts | 77 ++++++-- .../src/server/webkit/wkInput.ts | 41 ++--- tests/page/page-add-locator-handler.spec.ts | 2 +- 13 files changed, 296 insertions(+), 313 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiInput.ts b/packages/playwright-core/src/server/bidi/bidiInput.ts index 5e9875f3a2e62..30a6439745197 100644 --- a/packages/playwright-core/src/server/bidi/bidiInput.ts +++ b/packages/playwright-core/src/server/bidi/bidiInput.ts @@ -59,7 +59,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { } private async _performActions(progress: Progress, actions: bidi.Input.KeySourceAction[]) { - await this._session.send('input.performActions', { + await progress.race(this._session.send('input.performActions', { context: this._session.sessionId, actions: [ { @@ -68,8 +68,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { actions, } ] - }); - progress.throwIfAborted(); + })); } } @@ -96,7 +95,7 @@ export class RawMouseImpl implements input.RawMouse { // Bidi throws when x/y are not integers. x = Math.floor(x); y = Math.floor(y); - await this._session.send('input.performActions', { + await progress.race(this._session.send('input.performActions', { context: this._session.sessionId, actions: [ { @@ -105,12 +104,11 @@ export class RawMouseImpl implements input.RawMouse { actions: [{ type: 'scroll', x, y, deltaX, deltaY }], } ] - }); - progress.throwIfAborted(); + })); } private async _performActions(progress: Progress, actions: bidi.Input.PointerSourceAction[]) { - await this._session.send('input.performActions', { + await progress.race(this._session.send('input.performActions', { context: this._session.sessionId, actions: [ { @@ -122,8 +120,7 @@ export class RawMouseImpl implements input.RawMouse { actions, } ] - }); - progress.throwIfAborted(); + })); } } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index cd49380705acc..5bd2577dad98f 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -168,8 +168,7 @@ export abstract class BrowserContext extends SdkObject { async stopPendingOperations(reason: string) { // When using context reuse, stop pending operations to gracefully terminate all the actions // with a user-friendly error message containing operation log. - for (const controller of this._activeProgressControllers) - controller.abort(new Error(reason)); + await Promise.all(Array.from(this._activeProgressControllers).map(controller => controller.abort(reason))); // Let rejections in microtask generate events before returning. await new Promise(f => setTimeout(f, 0)); } diff --git a/packages/playwright-core/src/server/chromium/crDragDrop.ts b/packages/playwright-core/src/server/chromium/crDragDrop.ts index d10c36d370d1b..b54fe50f3f707 100644 --- a/packages/playwright-core/src/server/chromium/crDragDrop.ts +++ b/packages/playwright-core/src/server/chromium/crDragDrop.ts @@ -55,14 +55,13 @@ export class DragManager { async interceptDragCausedByMove(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, moveCallback: () => Promise): Promise { this._lastPosition = { x, y }; if (this._dragState) { - await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { + await progress.race(this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { type: 'dragOver', x, y, data: this._dragState, modifiers: toModifiersMask(modifiers), - }); - progress.throwIfAborted(); + })); return; } if (button !== 'left') @@ -111,18 +110,16 @@ export class DragManager { }))).some(x => x); this._dragState = expectingDrag ? (await dragInterceptedPromise).data : null; client.off('Input.dragIntercepted', onDragIntercepted!); - await client.send('Input.setInterceptDrags', { enabled: false }); - progress.throwIfAborted(); + await progress.race(client.send('Input.setInterceptDrags', { enabled: false })); if (this._dragState) { - await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { + await progress.race(this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { type: 'dragEnter', x, y, data: this._dragState, modifiers: toModifiersMask(modifiers), - }); - progress.throwIfAborted(); + })); } } @@ -132,14 +129,13 @@ export class DragManager { async drop(progress: Progress, x: number, y: number, modifiers: Set) { assert(this._dragState, 'missing drag state'); - await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { + await progress.race(this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { type: 'drop', x, y, data: this._dragState, modifiers: toModifiersMask(modifiers), - }); + })); this._dragState = null; - progress.throwIfAborted(); } } diff --git a/packages/playwright-core/src/server/chromium/crInput.ts b/packages/playwright-core/src/server/chromium/crInput.ts index 8e045fc5c7c57..beb030c9bff9a 100644 --- a/packages/playwright-core/src/server/chromium/crInput.ts +++ b/packages/playwright-core/src/server/chromium/crInput.ts @@ -55,11 +55,10 @@ export class RawKeyboardImpl implements input.RawKeyboard { async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { const { code, key, location, text } = description; - if (code === 'Escape' && await this._dragManger.cancelDrag()) + if (code === 'Escape' && await progress.race(this._dragManger.cancelDrag())) return; - progress.throwIfAborted(); const commands = this._commandsForCode(code, modifiers); - await this._client.send('Input.dispatchKeyEvent', { + await progress.race(this._client.send('Input.dispatchKeyEvent', { type: text ? 'keyDown' : 'rawKeyDown', modifiers: toModifiersMask(modifiers), windowsVirtualKeyCode: description.keyCodeWithoutLocation, @@ -71,26 +70,23 @@ export class RawKeyboardImpl implements input.RawKeyboard { autoRepeat, location, isKeypad: location === input.keypadLocation - }); - progress.throwIfAborted(); + })); } async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { const { code, key, location } = description; - await this._client.send('Input.dispatchKeyEvent', { + await progress.race(this._client.send('Input.dispatchKeyEvent', { type: 'keyUp', modifiers: toModifiersMask(modifiers), key, windowsVirtualKeyCode: description.keyCodeWithoutLocation, code, location - }); - progress.throwIfAborted(); + })); } async sendText(progress: Progress, text: string): Promise { - await this._client.send('Input.insertText', { text }); - progress.throwIfAborted(); + await progress.race(this._client.send('Input.insertText', { text })); } } @@ -107,7 +103,7 @@ export class RawMouseImpl implements input.RawMouse { async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { const actualMove = async () => { - await this._client.send('Input.dispatchMouseEvent', { + await progress.race(this._client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', button, buttons: toButtonsMask(buttons), @@ -115,13 +111,12 @@ export class RawMouseImpl implements input.RawMouse { y, modifiers: toModifiersMask(modifiers), force: buttons.size > 0 ? 0.5 : 0, - }); + })); }; if (forClick) { // Avoid extra protocol calls related to drag and drop, because click relies on // move-down-up protocol commands being sent synchronously. await actualMove(); - progress.throwIfAborted(); return; } await this._dragManager.interceptDragCausedByMove(progress, x, y, button, buttons, modifiers, actualMove); @@ -130,7 +125,7 @@ export class RawMouseImpl implements input.RawMouse { async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { if (this._dragManager.isDragging()) return; - await this._client.send('Input.dispatchMouseEvent', { + await progress.race(this._client.send('Input.dispatchMouseEvent', { type: 'mousePressed', button, buttons: toButtonsMask(buttons), @@ -139,8 +134,7 @@ export class RawMouseImpl implements input.RawMouse { modifiers: toModifiersMask(modifiers), clickCount, force: buttons.size > 0 ? 0.5 : 0, - }); - progress.throwIfAborted(); + })); } async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { @@ -148,7 +142,7 @@ export class RawMouseImpl implements input.RawMouse { await this._dragManager.drop(progress, x, y, modifiers); return; } - await this._client.send('Input.dispatchMouseEvent', { + await progress.race(this._client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', button, buttons: toButtonsMask(buttons), @@ -156,20 +150,18 @@ export class RawMouseImpl implements input.RawMouse { y, modifiers: toModifiersMask(modifiers), clickCount - }); - progress.throwIfAborted(); + })); } async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { - await this._client.send('Input.dispatchMouseEvent', { + await progress.race(this._client.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, modifiers: toModifiersMask(modifiers), deltaX, deltaY, - }); - progress.throwIfAborted(); + })); } } @@ -180,7 +172,7 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { this._client = client; } async tap(progress: Progress, x: number, y: number, modifiers: Set) { - await Promise.all([ + await progress.race(Promise.all([ this._client.send('Input.dispatchTouchEvent', { type: 'touchStart', modifiers: toModifiersMask(modifiers), @@ -193,7 +185,6 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { modifiers: toModifiersMask(modifiers), touchPoints: [] }), - ]); - progress.throwIfAborted(); + ])); } } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 835c9331b6f21..d6fb1c8ee1e0c 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -17,7 +17,7 @@ import fs from 'fs'; import * as js from './javascript'; -import { ProgressController } from './progress'; +import { isAbortError, ProgressController } from './progress'; import { asLocator, isUnderTest } from '../utils'; import { prepareFilesForUpload } from './fileUploadUtils'; import { isSessionClosedError } from './protocolError'; @@ -141,7 +141,7 @@ export class ElementHandle extends js.JSHandle { const utility = await this._frame._utilityContext(); return await utility.evaluate(pageFunction, [await utility.injectedScript(), this, arg]); } catch (e) { - if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) throw e; return 'error:notconnected'; } @@ -152,7 +152,7 @@ export class ElementHandle extends js.JSHandle { const utility = await this._frame._utilityContext(); return await utility.evaluateHandle(pageFunction, [await utility.injectedScript(), this, arg]); } catch (e) { - if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) throw e; return 'error:notconnected'; } @@ -240,25 +240,25 @@ export class ElementHandle extends js.JSHandle { return this._frame.dispatchEvent(metadata, ':scope', type, eventInit, { timeout: 0 }, this); } - async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> { - return await this._page.delegate.scrollRectIntoViewIfNeeded(this, rect); + async _scrollRectIntoViewIfNeeded(progress: Progress, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> { + return await progress.race(this._page.delegate.scrollRectIntoViewIfNeeded(this, rect)); } async _waitAndScrollIntoViewIfNeeded(progress: Progress, waitForVisible: boolean): Promise { const result = await this._retryAction(progress, 'scroll into view', async () => { progress.log(` waiting for element to be stable`); - const waitResult = await this.evaluateInUtility(async ([injected, node, { waitForVisible }]) => { + const waitResult = await progress.race(this.evaluateInUtility(async ([injected, node, { waitForVisible }]) => { return await injected.checkElementStates(node, waitForVisible ? ['visible', 'stable'] : ['stable']); - }, { waitForVisible }); + }, { waitForVisible })); if (waitResult) return waitResult; - return await this._scrollRectIntoViewIfNeeded(); + return await this._scrollRectIntoViewIfNeeded(progress); }, {}); assertDone(throwRetargetableDOMError(result)); } async scrollIntoViewIfNeeded(metadata: CallMetadata, options: types.TimeoutOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run( progress => this._waitAndScrollIntoViewIfNeeded(progress, false /* waitForVisible */), options.timeout); @@ -336,13 +336,14 @@ export class ElementHandle extends js.JSHandle { // We progressively wait longer between retries, up to 500ms. const waitTime = [0, 20, 100, 100, 500]; - while (progress.isRunning()) { + while (true) { + progress.throwIfAborted(); if (retry) { progress.log(`retrying ${actionName} action${options.trial ? ' (trial run)' : ''}`); const timeout = waitTime[Math.min(retry - 1, waitTime.length - 1)]; if (timeout) { progress.log(` waiting ${timeout}ms`); - const result = await this.evaluateInUtility(([injected, node, timeout]) => new Promise(f => setTimeout(f, timeout)), timeout); + const result = await progress.race(this.evaluateInUtility(([injected, node, timeout]) => new Promise(f => setTimeout(f, timeout)), timeout)); if (result === 'error:notconnected') return result; } @@ -379,11 +380,10 @@ export class ElementHandle extends js.JSHandle { } return result; } - return 'done'; } async _retryPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (point: types.Point) => Promise, - options: { waitAfter: boolean | 'disabled' } & types.PointerActionOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { + options: Omit<{ waitAfter: boolean | 'disabled' } & types.PointerActionOptions & types.PointerActionWaitOptions, 'timeout'>): Promise<'error:notconnected' | 'done'> { // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. const skipActionPreChecks = actionName === 'move and up'; // By default, we scroll with protocol method to reveal the action point. @@ -414,7 +414,7 @@ export class ElementHandle extends js.JSHandle { waitForEnabled: boolean, action: (point: types.Point) => Promise, forceScrollOptions: ScrollIntoViewOptions | undefined, - options: { waitAfter: boolean | 'disabled' } & types.PointerActionOptions & types.PointerActionWaitOptions, + options: Omit<{ waitAfter: boolean | 'disabled' } & types.PointerActionOptions & types.PointerActionWaitOptions, 'timeout'>, ): Promise { const { force = false, position } = options; @@ -426,7 +426,7 @@ export class ElementHandle extends js.JSHandle { return 'done' as const; }, forceScrollOptions); } - return await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined); + return await this._scrollRectIntoViewIfNeeded(progress, position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined); }; if (this._frame.parentFrame()) { @@ -434,53 +434,53 @@ export class ElementHandle extends js.JSHandle { // into view and visible, so they are not throttled. // See https://github.com/microsoft/playwright/issues/27196 for an example. progress.throwIfAborted(); // Avoid action that has side-effects. - await doScrollIntoView().catch(() => {}); + await progress.race(doScrollIntoView().catch(() => {})); } if ((options as any).__testHookBeforeStable) - await (options as any).__testHookBeforeStable(); + await progress.race((options as any).__testHookBeforeStable()); if (!force) { const elementStates: ElementState[] = waitForEnabled ? ['visible', 'enabled', 'stable'] : ['visible', 'stable']; progress.log(` waiting for element to be ${waitForEnabled ? 'visible, enabled and stable' : 'visible and stable'}`); - const result = await this.evaluateInUtility(async ([injected, node, { elementStates }]) => { + const result = await progress.race(this.evaluateInUtility(async ([injected, node, { elementStates }]) => { return await injected.checkElementStates(node, elementStates); - }, { elementStates }); + }, { elementStates })); if (result) return result; progress.log(` element is ${waitForEnabled ? 'visible, enabled and stable' : 'visible and stable'}`); } if ((options as any).__testHookAfterStable) - await (options as any).__testHookAfterStable(); + await progress.race((options as any).__testHookAfterStable()); progress.log(' scrolling into view if needed'); progress.throwIfAborted(); // Avoid action that has side-effects. - const scrolled = await doScrollIntoView(); + const scrolled = await progress.race(doScrollIntoView()); if (scrolled !== 'done') return scrolled; progress.log(' done scrolling'); - const maybePoint = position ? await this._offsetPoint(position) : await this._clickablePoint(); + const maybePoint = position ? await progress.race(this._offsetPoint(position)) : await progress.race(this._clickablePoint()); if (typeof maybePoint === 'string') return maybePoint; const point = roundPoint(maybePoint); progress.metadata.point = point; - await this.instrumentation.onBeforeInputAction(this, progress.metadata); + await progress.race(this.instrumentation.onBeforeInputAction(this, progress.metadata)); let hitTargetInterceptionHandle: js.JSHandle | undefined; if (force) { progress.log(` forcing action`); } else { if ((options as any).__testHookBeforeHitTarget) - await (options as any).__testHookBeforeHitTarget(); + await progress.race((options as any).__testHookBeforeHitTarget()); - const frameCheckResult = await this._checkFrameIsHitTarget(point); + const frameCheckResult = await progress.race(this._checkFrameIsHitTarget(point)); if (frameCheckResult === 'error:notconnected' || ('hitTargetDescription' in frameCheckResult)) return frameCheckResult; const hitPoint = frameCheckResult.framePoint; const actionType = actionName === 'move and up' ? 'drag' : ((actionName === 'hover' || actionName === 'tap') ? actionName : 'mouse'); - const handle = await this.evaluateHandleInUtility(([injected, node, { actionType, hitPoint, trial }]) => injected.setupHitTargetInterceptor(node, actionType, hitPoint, trial), { actionType, hitPoint, trial: !!options.trial } as const); + const handle = await progress.race(this.evaluateHandleInUtility(([injected, node, { actionType, hitPoint, trial }]) => injected.setupHitTargetInterceptor(node, actionType, hitPoint, trial), { actionType, hitPoint, trial: !!options.trial } as const)); if (handle === 'error:notconnected') return handle; if (!handle._objectId) { @@ -500,7 +500,7 @@ export class ElementHandle extends js.JSHandle { const actionResult = await this._page.frameManager.waitForSignalsCreatedBy(progress, options.waitAfter === true, async () => { if ((options as any).__testHookBeforePointerAction) - await (options as any).__testHookBeforePointerAction(); + await progress.race((options as any).__testHookBeforePointerAction()); progress.throwIfAborted(); // Avoid action that has side-effects. let restoreModifiers: types.KeyboardModifier[] | undefined; if (options && options.modifiers) @@ -518,7 +518,7 @@ export class ElementHandle extends js.JSHandle { if (options.waitAfter !== false) { // When noWaitAfter is passed, we do not want to accidentally stall on // non-committed navigation blocking the evaluate. - const hitTargetResult = await stopHitTargetInterception; + const hitTargetResult = await progress.race(stopHitTargetInterception); if (hitTargetResult !== 'done') return hitTargetResult; } @@ -526,7 +526,7 @@ export class ElementHandle extends js.JSHandle { progress.log(` ${options.trial ? 'trial ' : ''}${actionName} action done`); progress.log(' waiting for scheduled navigations to finish'); if ((options as any).__testHookAfterPointerAction) - await (options as any).__testHookAfterPointerAction(); + await progress.race((options as any).__testHookAfterPointerAction()); return 'done'; }); if (actionResult !== 'done') @@ -535,19 +535,19 @@ export class ElementHandle extends js.JSHandle { return 'done'; } - private async _markAsTargetElement(metadata: CallMetadata) { - if (!metadata.id) + private async _markAsTargetElement(progress: Progress) { + if (!progress.metadata.id) return; - await this.evaluateInUtility(([injected, node, callId]) => { + await progress.race(this.evaluateInUtility(([injected, node, callId]) => { if (node.nodeType === 1 /* Node.ELEMENT_NODE */) injected.markTargetElements(new Set([node as Node as Element]), callId); - }, metadata.id); + }, progress.metadata.id)); } async hover(metadata: CallMetadata, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._hover(progress, options); return assertDone(throwRetargetableDOMError(result)); }, options.timeout); @@ -558,9 +558,9 @@ export class ElementHandle extends js.JSHandle { } async click(metadata: CallMetadata, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._click(progress, { ...options, waitAfter: !options.noWaitAfter }); return assertDone(throwRetargetableDOMError(result)); }, options.timeout); @@ -571,9 +571,9 @@ export class ElementHandle extends js.JSHandle { } async dblclick(metadata: CallMetadata, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._dblclick(progress, options); return assertDone(throwRetargetableDOMError(result)); }, options.timeout); @@ -584,9 +584,9 @@ export class ElementHandle extends js.JSHandle { } async tap(metadata: CallMetadata, options: types.PointerActionWaitOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._tap(progress, options); return assertDone(throwRetargetableDOMError(result)); }, options.timeout); @@ -597,9 +597,9 @@ export class ElementHandle extends js.JSHandle { } async selectOption(metadata: CallMetadata, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._selectOption(progress, elements, values, options); return throwRetargetableDOMError(result); }, options.timeout); @@ -608,18 +608,18 @@ export class ElementHandle extends js.JSHandle { async _selectOption(progress: Progress, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise { let resultingOptions: string[] = []; const result = await this._retryAction(progress, 'select option', async () => { - await this.instrumentation.onBeforeInputAction(this, progress.metadata); + await progress.race(this.instrumentation.onBeforeInputAction(this, progress.metadata)); if (!options.force) progress.log(` waiting for element to be visible and enabled`); const optionsToSelect = [...elements, ...values]; - const result = await this.evaluateInUtility(async ([injected, node, { optionsToSelect, force }]) => { + const result = await progress.race(this.evaluateInUtility(async ([injected, node, { optionsToSelect, force }]) => { if (!force) { const checkResult = await injected.checkElementStates(node, ['visible', 'enabled']); if (checkResult) return checkResult; } return injected.selectOptions(node, optionsToSelect); - }, { optionsToSelect, force: options.force }); + }, { optionsToSelect, force: options.force })); if (Array.isArray(result)) { progress.log(' selected specified option(s)'); resultingOptions = result; @@ -633,9 +633,9 @@ export class ElementHandle extends js.JSHandle { } async fill(metadata: CallMetadata, value: string, options: types.CommonActionOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._fill(progress, value, options); assertDone(throwRetargetableDOMError(result)); }, options.timeout); @@ -644,17 +644,17 @@ export class ElementHandle extends js.JSHandle { async _fill(progress: Progress, value: string, options: types.CommonActionOptions): Promise<'error:notconnected' | 'done'> { progress.log(` fill("${value}")`); return await this._retryAction(progress, 'fill', async () => { - await this.instrumentation.onBeforeInputAction(this, progress.metadata); + await progress.race(this.instrumentation.onBeforeInputAction(this, progress.metadata)); if (!options.force) progress.log(' waiting for element to be visible, enabled and editable'); - const result = await this.evaluateInUtility(async ([injected, node, { value, force }]) => { + const result = await progress.race(this.evaluateInUtility(async ([injected, node, { value, force }]) => { if (!force) { const checkResult = await injected.checkElementStates(node, ['visible', 'enabled', 'editable']); if (checkResult) return checkResult; } return injected.fill(node, value); - }, { value, force: options.force }); + }, { value, force: options.force })); progress.throwIfAborted(); // Avoid action that has side-effects. if (result === 'needsinput') { if (value) @@ -669,29 +669,29 @@ export class ElementHandle extends js.JSHandle { } async selectText(metadata: CallMetadata, options: types.CommonActionOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { const result = await this._retryAction(progress, 'selectText', async () => { if (!options.force) progress.log(' waiting for element to be visible'); - return await this.evaluateInUtility(async ([injected, node, { force }]) => { + return await progress.race(this.evaluateInUtility(async ([injected, node, { force }]) => { if (!force) { const checkResult = await injected.checkElementStates(node, ['visible']); if (checkResult) return checkResult; } return injected.selectText(node); - }, { force: options.force }); + }, { force: options.force })); }, options); assertDone(throwRetargetableDOMError(result)); }, options.timeout); } async setInputFiles(metadata: CallMetadata, params: channels.ElementHandleSetInputFilesParams) { - const inputFileItems = await prepareFilesForUpload(this._frame, params); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + const inputFileItems = await progress.race(prepareFilesForUpload(this._frame, params)); + await this._markAsTargetElement(progress); const result = await this._setInputFiles(progress, inputFileItems); return assertDone(throwRetargetableDOMError(result)); }, params.timeout); @@ -700,7 +700,7 @@ export class ElementHandle extends js.JSHandle { async _setInputFiles(progress: Progress, items: InputFilesItems): Promise<'error:notconnected' | 'done'> { const { filePayloads, localPaths, localDirectory } = items; const multiple = filePayloads && filePayloads.length > 1 || localPaths && localPaths.length > 1; - const result = await this.evaluateHandleInUtility(([injected, node, { multiple, directoryUpload }]): Element | undefined => { + const result = await progress.race(this.evaluateHandleInUtility(([injected, node, { multiple, directoryUpload }]): Element | undefined => { const element = injected.retarget(node, 'follow-label'); if (!element) return; @@ -714,53 +714,50 @@ export class ElementHandle extends js.JSHandle { if (!directoryUpload && inputElement.webkitdirectory) throw injected.createStacklessError('[webkitdirectory] input requires passing a path to a directory'); return inputElement; - }, { multiple, directoryUpload: !!localDirectory }); + }, { multiple, directoryUpload: !!localDirectory })); if (result === 'error:notconnected' || !result.asElement()) return 'error:notconnected'; const retargeted = result.asElement() as ElementHandle; - await this.instrumentation.onBeforeInputAction(this, progress.metadata); - progress.throwIfAborted(); // Avoid action that has side-effects. + await progress.race(this.instrumentation.onBeforeInputAction(this, progress.metadata)); if (localPaths || localDirectory) { const localPathsOrDirectory = localDirectory ? [localDirectory] : localPaths!; - await Promise.all((localPathsOrDirectory).map(localPath => ( + await progress.race(Promise.all((localPathsOrDirectory).map(localPath => ( fs.promises.access(localPath, fs.constants.F_OK) - ))); + )))); // Browsers traverse the given directory asynchronously and we want to ensure all files are uploaded. const waitForInputEvent = localDirectory ? this.evaluate(node => new Promise(fulfill => { node.addEventListener('input', fulfill, { once: true }); })).catch(() => {}) : Promise.resolve(); - await this._page.delegate.setInputFilePaths(retargeted, localPathsOrDirectory); - await waitForInputEvent; + await progress.race(this._page.delegate.setInputFilePaths(retargeted, localPathsOrDirectory)); + await progress.race(waitForInputEvent); } else { - await retargeted.evaluateInUtility(([injected, node, files]) => - injected.setInputFiles(node, files), filePayloads!); + await progress.race(retargeted.evaluateInUtility(([injected, node, files]) => + injected.setInputFiles(node, files), filePayloads!)); } return 'done'; } async focus(metadata: CallMetadata): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); await controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._focus(progress); return assertDone(throwRetargetableDOMError(result)); }, 0); } async _focus(progress: Progress, resetSelectionIfNotFocused?: boolean): Promise<'error:notconnected' | 'done'> { - progress.throwIfAborted(); // Avoid action that has side-effects. - return await this.evaluateInUtility(([injected, node, resetSelectionIfNotFocused]) => injected.focusNode(node, resetSelectionIfNotFocused), resetSelectionIfNotFocused); + return await progress.race(this.evaluateInUtility(([injected, node, resetSelectionIfNotFocused]) => injected.focusNode(node, resetSelectionIfNotFocused), resetSelectionIfNotFocused)); } async _blur(progress: Progress): Promise<'error:notconnected' | 'done'> { - progress.throwIfAborted(); // Avoid action that has side-effects. - return await this.evaluateInUtility(([injected, node]) => injected.blurNode(node), {}); + return await progress.race(this.evaluateInUtility(([injected, node]) => injected.blurNode(node), {})); } async type(metadata: CallMetadata, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._type(progress, text, options); return assertDone(throwRetargetableDOMError(result)); }, options.timeout); @@ -768,19 +765,18 @@ export class ElementHandle extends js.JSHandle { async _type(progress: Progress, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions): Promise<'error:notconnected' | 'done'> { progress.log(`elementHandle.type("${text}")`); - await this.instrumentation.onBeforeInputAction(this, progress.metadata); + await progress.race(this.instrumentation.onBeforeInputAction(this, progress.metadata)); const result = await this._focus(progress, true /* resetSelectionIfNotFocused */); if (result !== 'done') return result; - progress.throwIfAborted(); // Avoid action that has side-effects. await this._page.keyboard._type(progress, text, options); return 'done'; } async press(metadata: CallMetadata, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this._markAsTargetElement(metadata); + await this._markAsTargetElement(progress); const result = await this._press(progress, key, options); return assertDone(throwRetargetableDOMError(result)); }, options.timeout); @@ -788,19 +784,18 @@ export class ElementHandle extends js.JSHandle { async _press(progress: Progress, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions): Promise<'error:notconnected' | 'done'> { progress.log(`elementHandle.press("${key}")`); - await this.instrumentation.onBeforeInputAction(this, progress.metadata); + await progress.race(this.instrumentation.onBeforeInputAction(this, progress.metadata)); return this._page.frameManager.waitForSignalsCreatedBy(progress, !options.noWaitAfter, async () => { const result = await this._focus(progress, true /* resetSelectionIfNotFocused */); if (result !== 'done') return result; - progress.throwIfAborted(); // Avoid action that has side-effects. await this._page.keyboard._press(progress, key, options); return 'done'; }); } async check(metadata: CallMetadata, options: { position?: types.Point } & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { const result = await this._setChecked(progress, true, options); return assertDone(throwRetargetableDOMError(result)); @@ -808,7 +803,7 @@ export class ElementHandle extends js.JSHandle { } async uncheck(metadata: CallMetadata, options: { position?: types.Point } & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { const result = await this._setChecked(progress, false, options); return assertDone(throwRetargetableDOMError(result)); @@ -817,12 +812,12 @@ export class ElementHandle extends js.JSHandle { async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { const isChecked = async () => { - const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {}); + const result = await progress.race(this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {})); if (result === 'error:notconnected' || result.received === 'error:notconnected') throwElementIsNotAttached(); return result.matches; }; - await this._markAsTargetElement(progress.metadata); + await this._markAsTargetElement(progress); if (await isChecked() === state) return 'done'; const result = await this._click(progress, { ...options, waitAfter: 'disabled' }); @@ -891,13 +886,13 @@ export class ElementHandle extends js.JSHandle { } async waitForElementState(metadata: CallMetadata, state: 'visible' | 'hidden' | 'stable' | 'enabled' | 'disabled' | 'editable', options: types.TimeoutOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { const actionName = `wait for ${state}`; const result = await this._retryAction(progress, actionName, async () => { - return await this.evaluateInUtility(async ([injected, node, state]) => { + return await progress.race(this.evaluateInUtility(async ([injected, node, state]) => { return (await injected.checkElementStates(node, [state])) || 'done'; - }, state); + }, state)); }, {}); assertDone(throwRetargetableDOMError(result)); }, options.timeout); diff --git a/packages/playwright-core/src/server/firefox/ffInput.ts b/packages/playwright-core/src/server/firefox/ffInput.ts index 9485c4a88116b..4a497b99f0795 100644 --- a/packages/playwright-core/src/server/firefox/ffInput.ts +++ b/packages/playwright-core/src/server/firefox/ffInput.ts @@ -68,7 +68,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { if (text === '\r') text = ''; const { code, key, location } = description; - await this._client.send('Page.dispatchKeyEvent', { + await progress.race(this._client.send('Page.dispatchKeyEvent', { type: 'keydown', keyCode: description.keyCodeWithoutLocation, code, @@ -76,26 +76,23 @@ export class RawKeyboardImpl implements input.RawKeyboard { repeat: autoRepeat, location, text, - }); - progress.throwIfAborted(); + })); } async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { const { code, key, location } = description; - await this._client.send('Page.dispatchKeyEvent', { + await progress.race(this._client.send('Page.dispatchKeyEvent', { type: 'keyup', key, keyCode: description.keyCodeWithoutLocation, code, location, repeat: false - }); - progress.throwIfAborted(); + })); } async sendText(progress: Progress, text: string): Promise { - await this._client.send('Page.insertText', { text }); - progress.throwIfAborted(); + await progress.race(this._client.send('Page.insertText', { text })); } } @@ -108,19 +105,18 @@ export class RawMouseImpl implements input.RawMouse { } async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - await this._client.send('Page.dispatchMouseEvent', { + await progress.race(this._client.send('Page.dispatchMouseEvent', { type: 'mousemove', button: 0, buttons: toButtonsMask(buttons), x: Math.floor(x), y: Math.floor(y), modifiers: toModifiersMask(modifiers) - }); - progress.throwIfAborted(); + })); } async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - await this._client.send('Page.dispatchMouseEvent', { + await progress.race(this._client.send('Page.dispatchMouseEvent', { type: 'mousedown', button: toButtonNumber(button), buttons: toButtonsMask(buttons), @@ -128,12 +124,11 @@ export class RawMouseImpl implements input.RawMouse { y: Math.floor(y), modifiers: toModifiersMask(modifiers), clickCount - }); - progress.throwIfAborted(); + })); } async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - await this._client.send('Page.dispatchMouseEvent', { + await progress.race(this._client.send('Page.dispatchMouseEvent', { type: 'mouseup', button: toButtonNumber(button), buttons: toButtonsMask(buttons), @@ -141,22 +136,20 @@ export class RawMouseImpl implements input.RawMouse { y: Math.floor(y), modifiers: toModifiersMask(modifiers), clickCount - }); - progress.throwIfAborted(); + })); } async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { // Wheel events hit the compositor first, so wait one frame for it to be synced. await this._page!.mainFrame().evaluateExpression(`new Promise(requestAnimationFrame)`, { world: 'utility' }); - await this._client.send('Page.dispatchWheelEvent', { + await progress.race(this._client.send('Page.dispatchWheelEvent', { deltaX, deltaY, x: Math.floor(x), y: Math.floor(y), deltaZ: 0, modifiers: toModifiersMask(modifiers) - }); - progress.throwIfAborted(); + })); } setPage(page: Page) { @@ -171,11 +164,10 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { this._client = client; } async tap(progress: Progress, x: number, y: number, modifiers: Set) { - await this._client.send('Page.dispatchTapEvent', { + await progress.race(this._client.send('Page.dispatchTapEvent', { x, y, modifiers: toModifiersMask(modifiers), - }); - progress.throwIfAborted(); + })); } } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index d173770292a3c..11981950fc1dc 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -25,7 +25,7 @@ import { SdkObject, serverSideCallMetadata } from './instrumentation'; import * as js from './javascript'; import * as network from './network'; import { Page } from './page'; -import { ProgressController } from './progress'; +import { isAbortError, ProgressController } from './progress'; import * as types from './types'; import { LongStandingScope, asLocator, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime, renderTitleForCall } from '../utils'; import { isSessionClosedError } from './protocolError'; @@ -160,15 +160,14 @@ export class FrameManager { } } - async waitForSignalsCreatedBy(progress: Progress | null, waitAfter: boolean, action: () => Promise): Promise { + async waitForSignalsCreatedBy(progress: Progress, waitAfter: boolean, action: () => Promise): Promise { if (!waitAfter) return action(); const barrier = new SignalBarrier(progress); this._signalBarriers.add(barrier); - if (progress) - progress.cleanupWhenAborted(() => this._signalBarriers.delete(barrier)); + progress.cleanupWhenAborted(() => this._signalBarriers.delete(barrier)); const result = await action(); - await this._page.delegate.inputActionEpilogue(); + await progress.race(this._page.delegate.inputActionEpilogue()); await barrier.waitFor(); this._signalBarriers.delete(barrier); // Resolve in the next task, after all waitForNavigations. @@ -745,15 +744,15 @@ export class Frame extends SdkObject { } async waitForSelector(metadata: CallMetadata, selector: string, options: types.WaitForElementOptions, scope?: dom.ElementHandle): Promise | null> { - const controller = new ProgressController(metadata, this); - if ((options as any).visibility) - throw new Error('options.visibility is not supported, did you mean options.state?'); - if ((options as any).waitFor && (options as any).waitFor !== 'visible') - throw new Error('options.waitFor is not supported, did you mean options.state?'); - const { state = 'visible' } = options; - if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) - throw new Error(`state: expected one of (attached|detached|visible|hidden)`); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { + if ((options as any).visibility) + throw new Error('options.visibility is not supported, did you mean options.state?'); + if ((options as any).waitFor && (options as any).waitFor !== 'visible') + throw new Error('options.waitFor is not supported, did you mean options.state?'); + const { state = 'visible' } = options; + if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) + throw new Error(`state: expected one of (attached|detached|visible|hidden)`); progress.log(`waiting for ${this._asLocator(selector)}${state === 'attached' ? '' : ' to be ' + state}`); return await this.waitForSelectorInternal(progress, selector, true, options, scope); }, options.timeout); @@ -765,14 +764,13 @@ export class Frame extends SdkObject { if (performActionPreChecks) await this._page.performActionPreChecks(progress); - const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope); - progress.throwIfAborted(); + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); if (!resolved) { if (state === 'hidden' || state === 'detached') return null; return continuePolling; } - const result = await resolved.injected.evaluateHandle((injected, { info, root }) => { + const result = await progress.raceWithCleanup(resolved.injected.evaluateHandle((injected, { info, root }) => { if (root && !root.isConnected) throw injected.createStacklessError('Element is not attached to the DOM'); const elements = injected.querySelectorAll(info.parsed, root || document); @@ -787,8 +785,8 @@ export class Frame extends SdkObject { log = ` locator resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`; } return { log, element, visible, attached: !!element }; - }, { info: resolved.info, root: resolved.frame === this ? scope : undefined }); - const { log, visible, attached } = await result.evaluate(r => ({ log: r.log, visible: r.visible, attached: r.attached })); + }, { info: resolved.info, root: resolved.frame === this ? scope : undefined }), handle => handle.dispose()); + const { log, visible, attached } = await progress.race(result.evaluate(r => ({ log: r.log, visible: r.visible, attached: r.attached }))); if (log) progress.log(log); const success = { attached, detached: !attached, visible, hidden: !visible }[state]; @@ -800,14 +798,15 @@ export class Frame extends SdkObject { result.dispose(); return null; } - const element = state === 'attached' || state === 'visible' ? await result.evaluateHandle(r => r.element) : null; + const element = state === 'attached' || state === 'visible' ? await progress.race(result.evaluateHandle(r => r.element)) : null; result.dispose(); if (!element) return null; if ((options as any).__testHookBeforeAdoptNode) - await (options as any).__testHookBeforeAdoptNode(); + await progress.race((options as any).__testHookBeforeAdoptNode()); try { - return await element._adoptTo(await resolved.frame._mainContext()); + const mainContext = await progress.race(resolved.frame._mainContext()); + return await progress.race(element._adoptTo(mainContext)); } catch (e) { return continuePolling; } @@ -865,7 +864,7 @@ export class Frame extends SdkObject { return retVal; }); } catch (e) { - if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) throw e; throw new Error(`Unable to retrieve content because the page is navigating and changing the content.`); } @@ -1043,18 +1042,17 @@ export class Frame extends SdkObject { const continuePolling = Symbol('continuePolling'); timeouts = [0, ...timeouts]; let timeoutIndex = 0; - while (progress.isRunning()) { + while (true) { const timeout = timeouts[Math.min(timeoutIndex++, timeouts.length - 1)]; if (timeout) { // Make sure we react immediately upon page close or frame detach. // We need this to show expected/received values in time. const actionPromise = new Promise(f => setTimeout(f, timeout)); - await LongStandingScope.raceMultiple([ + await progress.race(LongStandingScope.raceMultiple([ this._page.openScope, this._detachedScope, - ], actionPromise); + ], actionPromise)); } - progress.throwIfAborted(); try { const result = await action(continuePolling); if (result === continuePolling) @@ -1066,11 +1064,11 @@ export class Frame extends SdkObject { continue; } } - progress.throwIfAborted(); - return undefined as any; } private _isErrorThatCannotBeRetried(e: Error) { + if (isAbortError(e)) + return true; // Always fail on JavaScript errors or when the main connection is closed. if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) return true; @@ -1095,11 +1093,10 @@ export class Frame extends SdkObject { if (performActionPreChecks) await this._page.performActionPreChecks(progress); - const resolved = await this.selectors.resolveInjectedForSelector(selector, { strict }); - progress.throwIfAborted(); + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, { strict })); if (!resolved) return continuePolling; - const result = await resolved.injected.evaluateHandle((injected, { info, callId }) => { + const result = await progress.raceWithCleanup(resolved.injected.evaluateHandle((injected, { info, callId }) => { const elements = injected.querySelectorAll(info.parsed, document); if (callId) injected.markTargetElements(new Set(elements), callId); @@ -1113,15 +1110,15 @@ export class Frame extends SdkObject { log = ` locator resolved to ${injected.previewNode(element)}`; } return { log, success: !!element, element }; - }, { info: resolved.info, callId: progress.metadata.id }); - const { log, success } = await result.evaluate(r => ({ log: r.log, success: r.success })); + }, { info: resolved.info, callId: progress.metadata.id }), handle => handle.dispose()); + const { log, success } = await progress.race(result.evaluate(r => ({ log: r.log, success: r.success }))); if (log) progress.log(log); if (!success) { result.dispose(); return continuePolling; } - const element = await result.evaluateHandle(r => r.element) as dom.ElementHandle; + const element = await progress.race(result.evaluateHandle(r => r.element)) as dom.ElementHandle; result.dispose(); try { const result = await action(element); @@ -1144,21 +1141,21 @@ export class Frame extends SdkObject { } async click(metadata: CallMetadata, selector: string, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._click(progress, { ...options, waitAfter: !options.noWaitAfter }))); }, options.timeout); } async dblclick(metadata: CallMetadata, selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._dblclick(progress, options))); }, options.timeout); } async dragAndDrop(metadata: CallMetadata, source: string, target: string, options: types.DragActionOptions & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, options.strict, !options.force /* performActionPreChecks */, async handle => { return handle._retryPointerAction(progress, 'move and down', false, async point => { @@ -1168,7 +1165,6 @@ export class Frame extends SdkObject { ...options, waitAfter: 'disabled', position: options.sourcePosition, - timeout: progress.timeUntilDeadline(), }); })); // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. @@ -1180,37 +1176,36 @@ export class Frame extends SdkObject { ...options, waitAfter: 'disabled', position: options.targetPosition, - timeout: progress.timeUntilDeadline(), }); })); }, options.timeout); } async tap(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions) { - if (!this._page.browserContext._options.hasTouch) - throw new Error('The page does not support tap. Use hasTouch context option to enable touch support.'); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { + if (!this._page.browserContext._options.hasTouch) + throw new Error('The page does not support tap. Use hasTouch context option to enable touch support.'); return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._tap(progress, options))); }, options.timeout); } async fill(metadata: CallMetadata, selector: string, value: string, options: types.TimeoutOptions & types.StrictOptions & { force?: boolean }) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._fill(progress, value, options))); }, options.timeout); } async focus(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._focus(progress))); }, options.timeout); } async blur(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._blur(progress))); }, options.timeout); @@ -1274,25 +1269,25 @@ export class Frame extends SdkObject { } async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { progress.log(` checking visibility of ${this._asLocator(selector)}`); - return await this.isVisibleInternal(selector, options, scope); + return await this.isVisibleInternal(progress, selector, options, scope); }, 0); // Note: isVisible is a one-shot operation without a timeout. } - async isVisibleInternal(selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { + async isVisibleInternal(progress: Progress, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { try { - const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope); + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); if (!resolved) return false; - return await resolved.injected.evaluate((injected, { info, root }) => { + return await progress.race(resolved.injected.evaluate((injected, { info, root }) => { const element = injected.querySelector(info.parsed, root || document, info.strict); const state = element ? injected.elementState(element, 'visible') : { matches: false, received: 'error:notconnected' }; return state.matches; - }, { info: resolved.info, root: resolved.frame === this ? scope : undefined }); + }, { info: resolved.info, root: resolved.frame === this ? scope : undefined })); } catch (e) { - if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e)) + if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e)) throw e; return false; } @@ -1319,14 +1314,14 @@ export class Frame extends SdkObject { } async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._hover(progress, options))); }, options.timeout); } async selectOption(metadata: CallMetadata, selector: string, elements: dom.ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._selectOption(progress, elements, values, options)); }, options.timeout); @@ -1334,45 +1329,43 @@ export class Frame extends SdkObject { async setInputFiles(metadata: CallMetadata, selector: string, params: channels.FrameSetInputFilesParams): Promise { const inputFileItems = await prepareFilesForUpload(this, params); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, params.strict, true /* performActionPreChecks */, handle => handle._setInputFiles(progress, inputFileItems))); }, params.timeout); } async type(metadata: CallMetadata, selector: string, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._type(progress, text, options))); }, options.timeout); } async press(metadata: CallMetadata, selector: string, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._press(progress, key, options))); }, options.timeout); } async check(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._setChecked(progress, true, options))); }, options.timeout); } async uncheck(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._setChecked(progress, false, options))); }, options.timeout); } async waitForTimeout(metadata: CallMetadata, timeout: number) { - const controller = new ProgressController(metadata, this); - return controller.run(async () => { - await new Promise(resolve => setTimeout(resolve, timeout)); - }); + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => progress.wait(timeout)); } async ariaSnapshot(metadata: CallMetadata, selector: string, options: { forAI?: boolean } & types.TimeoutOptions): Promise { @@ -1413,7 +1406,7 @@ export class Frame extends SdkObject { if (resultOneShot.matches !== options.isNot) return resultOneShot; } catch (e) { - if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) + if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) throw e; // Ignore any other errors from one-shot, we'll handle them during retries. } @@ -1593,16 +1586,15 @@ export class Frame extends SdkObject { } private async _callOnElementOnceMatches(metadata: CallMetadata, selector: string, body: ElementCallback, taskData: T, options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean }, scope?: dom.ElementHandle): Promise { - const callbackText = body.toString(); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { + const callbackText = body.toString(); progress.log(`waiting for ${this._asLocator(selector)}`); const promise = this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => { - const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope); - progress.throwIfAborted(); + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); if (!resolved) return continuePolling; - const { log, success, value } = await resolved.injected.evaluate((injected, { info, callbackText, taskData, callId, root }) => { + const { log, success, value } = await progress.race(resolved.injected.evaluate((injected, { info, callbackText, taskData, callId, root }) => { const callback = injected.eval(callbackText) as ElementCallback; const element = injected.querySelector(info.parsed, root || document, info.strict); if (!element) @@ -1611,8 +1603,7 @@ export class Frame extends SdkObject { if (callId) injected.markTargetElements(new Set([element]), callId); return { log, success: true, value: callback(injected, element, taskData as T) }; - }, { info: resolved.info, callbackText, taskData, callId: progress.metadata.id, root: resolved.frame === this ? scope : undefined }); - + }, { info: resolved.info, callbackText, taskData, callId: progress.metadata.id, root: resolved.frame === this ? scope : undefined })); if (log) progress.log(log); if (!success) @@ -1724,38 +1715,39 @@ export class Frame extends SdkObject { } class SignalBarrier { - private _progress: Progress | null; + private _progress: Progress; private _protectCount = 0; private _promise = new ManualPromise(); - constructor(progress: Progress | null) { + constructor(progress: Progress) { this._progress = progress; this.retain(); } waitFor(): PromiseLike { this.release(); - return this._promise; + return this._progress.race(this._promise); } - async addFrameNavigation(frame: Frame) { + addFrameNavigation(frame: Frame) { // Auto-wait top-level navigations only. if (frame.parentFrame()) return; this.retain(); - const waiter = helper.waitForEvent(null, frame, Frame.Events.InternalNavigation, (e: NavigationEvent) => { + const waiter = helper.waitForEvent(this._progress, frame, Frame.Events.InternalNavigation, (e: NavigationEvent) => { if (!e.isPublic) return false; if (!e.error && this._progress) this._progress.log(` navigated to "${frame._url}"`); return true; }); - await LongStandingScope.raceMultiple([ + LongStandingScope.raceMultiple([ frame._page.openScope, frame._detachedScope, - ], waiter.promise).catch(() => {}); - waiter.dispose(); - this.release(); + ], waiter.promise).catch(() => {}).finally(() => { + waiter.dispose(); + this.release(); + }); } retain() { diff --git a/packages/playwright-core/src/server/helper.ts b/packages/playwright-core/src/server/helper.ts index e9fec62419f66..e5bf38dd7777c 100644 --- a/packages/playwright-core/src/server/helper.ts +++ b/packages/playwright-core/src/server/helper.ts @@ -55,7 +55,7 @@ class Helper { return null; } - static waitForEvent(progress: Progress | null, emitter: EventEmitter, event: string | symbol, predicate?: Function): { promise: Promise, dispose: () => void } { + static waitForEvent(progress: Progress, emitter: EventEmitter, event: string | symbol, predicate?: Function): { promise: Promise, dispose: () => void } { const listeners: RegisteredListener[] = []; const promise = new Promise((resolve, reject) => { listeners.push(eventsHelper.addEventListener(emitter, event, eventArg => { @@ -71,8 +71,7 @@ class Helper { })); }); const dispose = () => eventsHelper.removeEventListeners(listeners); - if (progress) - progress.cleanupWhenAborted(dispose); + progress.cleanupWhenAborted(dispose); return { promise, dispose }; } diff --git a/packages/playwright-core/src/server/input.ts b/packages/playwright-core/src/server/input.ts index a82dc69ae8b11..99c7fea2dc7cc 100644 --- a/packages/playwright-core/src/server/input.ts +++ b/packages/playwright-core/src/server/input.ts @@ -55,7 +55,7 @@ export class Keyboard { } async down(metadata: CallMetadata, key: string) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._down(progress, key)); } @@ -82,7 +82,7 @@ export class Keyboard { } async up(metadata: CallMetadata, key: string) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._up(progress, key)); } @@ -95,7 +95,7 @@ export class Keyboard { } async insertText(metadata: CallMetadata, text: string) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._insertText(progress, text)); } @@ -104,7 +104,7 @@ export class Keyboard { } async type(metadata: CallMetadata, text: string, options?: { delay?: number }) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._type(progress, text, options)); } @@ -115,14 +115,14 @@ export class Keyboard { await this._press(progress, char, { delay }); } else { if (delay) - await wait(progress, delay); + await progress.wait(delay); await this._insertText(progress, char); } } } async press(metadata: CallMetadata, key: string, options: { delay?: number }) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._press(progress, key, options)); } @@ -148,7 +148,7 @@ export class Keyboard { await this._down(progress, tokens[i]); await this._down(progress, key); if (options.delay) - await wait(progress, options.delay); + await progress.wait(options.delay); await this._up(progress, key); for (let i = tokens.length - 2; i >= 0; --i) await this._up(progress, tokens[i]); @@ -214,7 +214,7 @@ export class Mouse { } async move(metadata: CallMetadata, x: number, y: number, options: { steps?: number, forClick?: boolean }) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._move(progress, x, y, options)); } @@ -232,7 +232,7 @@ export class Mouse { } async down(metadata: CallMetadata, options: { button?: types.MouseButton, clickCount?: number }) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._down(progress, options)); } @@ -244,7 +244,7 @@ export class Mouse { } async up(metadata: CallMetadata, options: { button?: types.MouseButton, clickCount?: number }) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._up(progress, options)); } @@ -256,7 +256,7 @@ export class Mouse { } async click(metadata: CallMetadata, x: number, y: number, options: { delay?: number, button?: types.MouseButton, clickCount?: number }) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._click(progress, x, y, options)); } @@ -266,10 +266,10 @@ export class Mouse { this._move(progress, x, y, { forClick: true }); for (let cc = 1; cc <= clickCount; ++cc) { await this._down(progress, { ...options, clickCount: cc }); - await wait(progress, delay); + await progress.wait(delay); await this._up(progress, { ...options, clickCount: cc }); if (cc < clickCount) - await wait(progress, delay); + await progress.wait(delay); } } else { const promises = []; @@ -283,7 +283,7 @@ export class Mouse { } async wheel(metadata: CallMetadata, deltaX: number, deltaY: number) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(async progress => { await this._raw.wheel(progress, this._x, this._y, this._buttons, this._keyboard._modifiers(), deltaX, deltaY); }); @@ -364,7 +364,7 @@ export class Touchscreen { } async tap(metadata: CallMetadata, x: number, y: number) { - const controller = new ProgressController(metadata, this._page); + const controller = new ProgressController(metadata, this._page, 'strict'); return controller.run(progress => this._tap(progress, x, y)); } @@ -374,8 +374,3 @@ export class Touchscreen { await this._raw.tap(progress, x, y, this._page.keyboard._modifiers()); } } - -async function wait(progress: Progress, ms: number) { - await new Promise(f => setTimeout(f, Math.min(ms, progress.timeUntilDeadline()))); - progress.throwIfAborted(); -} diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index b2ca066122c75..b2a24a620fe01 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -25,7 +25,7 @@ import { helper } from './helper'; import * as input from './input'; import { SdkObject } from './instrumentation'; import * as js from './javascript'; -import { ProgressController } from './progress'; +import { isAbortError, ProgressController } from './progress'; import { Screenshotter, validateScreenshotOptions } from './screenshotter'; import { LongStandingScope, assert, renderTitleForCall, trimStringWithEllipsis } from '../utils'; import { asLocator } from '../utils'; @@ -36,6 +36,7 @@ import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { parseEvaluationResultValue } from '../utils/isomorphic/utilityScriptSerializers'; import { compressCallLog } from './callLog'; import * as rawBindingsControllerSource from '../generated/bindingsControllerSource'; +import { isSessionClosedError } from './protocolError'; import type { Artifact } from './artifact'; import type * as dom from './dom'; @@ -476,7 +477,7 @@ export class Page extends SdkObject { return; for (const [uid, handler] of this._locatorHandlers) { if (!handler.resolved) { - if (await this.mainFrame().isVisibleInternal(handler.selector, { strict: true })) { + if (await this.mainFrame().isVisibleInternal(progress, handler.selector, { strict: true })) { handler.resolved = new ManualPromise(); this.emit(Page.Events.LocatorHandlerTriggered, uid); } @@ -493,9 +494,7 @@ export class Page extends SdkObject { progress.log(` locator handler has finished`); } }); - await this.openScope.race(promise).finally(() => --this._locatorHandlerRunningCounter); - // Avoid side-effects after long-running operation. - progress.throwIfAborted(); + await progress.race(this.openScope.race(promise)).finally(() => --this._locatorHandlerRunningCounter); progress.log(` interception handler has finished, continuing`); } } @@ -1005,7 +1004,7 @@ async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, f return continuePolling; return snapshotOrRetry; } catch (e) { - if (js.isJavaScriptErrorInEvaluate(e)) + if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e)) throw e; return continuePolling; } diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index 68476ca21dd7a..e01576bbc8a00 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -24,27 +24,37 @@ import type { LogName } from './utils/debugLogger'; export interface Progress { log(message: string): void; timeUntilDeadline(): number; - isRunning(): boolean; cleanupWhenAborted(cleanup: () => any): void; throwIfAborted(): void; + race(promise: Promise | Promise[]): Promise; + raceWithCleanup(promise: Promise, cleanup: (result: T) => any): Promise; + wait(timeout: number): Promise; metadata: CallMetadata; } export class ProgressController { private _forceAbortPromise = new ManualPromise(); + private _donePromise = new ManualPromise(); // Cleanups to be run only in the case of abort. private _cleanups: (() => any)[] = []; + // Lenient mode races against the timeout. This guarantees that timeout is respected, + // but may have some work being done after the timeout due to parallel control flow. + // + // Strict mode aborts the progress and requires the code to react to it. This way, + // progress only finishes after the inner callback exits, guaranteeing no work after the timeout. + private _strictMode = false; + private _logName: LogName; - private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before'; + private _state: 'before' | 'running' | { error: Error } | 'finished' = 'before'; private _deadline: number = 0; - private _timeout: number = 0; readonly metadata: CallMetadata; readonly instrumentation: Instrumentation; readonly sdkObject: SdkObject; - constructor(metadata: CallMetadata, sdkObject: SdkObject) { + constructor(metadata: CallMetadata, sdkObject: SdkObject, strictMode?: 'strict') { + this._strictMode = strictMode === 'strict'; this.metadata = metadata; this.sdkObject = sdkObject; this.instrumentation = sdkObject.instrumentation; @@ -56,15 +66,18 @@ export class ProgressController { this._logName = logName; } - abort(error: Error) { - this._forceAbortPromise.reject(error); + async abort(message: string) { + if (this._state === 'running') { + const error = new AbortedError(message); + this._state = { error }; + this._forceAbortPromise.reject(error); + } + if (this._strictMode) + await this._donePromise; } async run(task: (progress: Progress) => Promise, timeout?: number): Promise { - if (timeout) { - this._timeout = timeout; - this._deadline = timeout ? monotonicTime() + timeout : 0; - } + this._deadline = timeout ? monotonicTime() + timeout : 0; assert(this._state === 'before'); this._state = 'running'; @@ -78,7 +91,6 @@ export class ProgressController { this.instrumentation.onCallLog(this.sdkObject, this.metadata, this._logName, message); }, timeUntilDeadline: () => this._deadline ? this._deadline - monotonicTime() : 2147483647, // 2^31-1 safe setTimeout in Node. - isRunning: () => this._state === 'running', cleanupWhenAborted: (cleanup: () => any) => { if (this._state === 'running') this._cleanups.push(cleanup); @@ -86,26 +98,47 @@ export class ProgressController { runCleanup(cleanup); }, throwIfAborted: () => { - if (this._state === 'aborted') - throw new AbortedError(); + if (typeof this._state === 'object') + throw this._state.error; + }, + metadata: this.metadata, + race: (promise: Promise | Promise[]) => { + const promises = Array.isArray(promise) ? promise : [promise]; + return Promise.race([...promises, this._forceAbortPromise]); + }, + raceWithCleanup: (promise: Promise, cleanup: (result: T) => any) => { + return progress.race(promise.then(result => { + progress.cleanupWhenAborted(() => cleanup(result)); + return result; + })); + }, + wait: async (timeout: number) => { + let timer: NodeJS.Timeout; + const promise = new Promise(f => timer = setTimeout(f, timeout)); + return progress.race(promise).finally(() => clearTimeout(timer)); }, - metadata: this.metadata }; - const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`); - const timer = setTimeout(() => this._forceAbortPromise.reject(timeoutError), progress.timeUntilDeadline()); + const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded.`); + const timer = setTimeout(() => { + if (this._state === 'running') { + this._state = { error: timeoutError }; + this._forceAbortPromise.reject(timeoutError); + } + }, progress.timeUntilDeadline()); try { const promise = task(progress); - const result = await Promise.race([promise, this._forceAbortPromise]); + const result = this._strictMode ? await promise : await Promise.race([promise, this._forceAbortPromise]); this._state = 'finished'; return result; - } catch (e) { - this._state = 'aborted'; + } catch (error) { + this._state = { error }; await Promise.all(this._cleanups.splice(0).map(runCleanup)); - throw e; + throw error; } finally { this.sdkObject.attribution.context?._activeProgressControllers.delete(this); clearTimeout(timer); + this._donePromise.resolve(); } } } @@ -118,3 +151,7 @@ async function runCleanup(cleanup: () => any) { } class AbortedError extends Error {} + +export function isAbortError(error: Error): boolean { + return error instanceof AbortedError || error instanceof TimeoutError; +} diff --git a/packages/playwright-core/src/server/webkit/wkInput.ts b/packages/playwright-core/src/server/webkit/wkInput.ts index 148e96b29d82c..3d1dffd1963e2 100644 --- a/packages/playwright-core/src/server/webkit/wkInput.ts +++ b/packages/playwright-core/src/server/webkit/wkInput.ts @@ -73,7 +73,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { let commands = macEditingCommands[shortcut]; if (isString(commands)) commands = [commands]; - await this._pageProxySession.send('Input.dispatchKeyEvent', { + await progress.race(this._pageProxySession.send('Input.dispatchKeyEvent', { type: 'keyDown', modifiers: toModifiersMask(modifiers), windowsVirtualKeyCode: keyCode, @@ -84,26 +84,23 @@ export class RawKeyboardImpl implements input.RawKeyboard { autoRepeat, macCommands: commands, isKeypad: description.location === input.keypadLocation - }); - progress.throwIfAborted(); + })); } async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { const { code, key } = description; - await this._pageProxySession.send('Input.dispatchKeyEvent', { + await progress.race(this._pageProxySession.send('Input.dispatchKeyEvent', { type: 'keyUp', modifiers: toModifiersMask(modifiers), key, windowsVirtualKeyCode: description.keyCode, code, isKeypad: description.location === input.keypadLocation - }); - progress.throwIfAborted(); + })); } async sendText(progress: Progress, text: string): Promise { - await this._session!.send('Page.insertText', { text }); - progress.throwIfAborted(); + await progress.race(this._session!.send('Page.insertText', { text })); } } @@ -121,19 +118,18 @@ export class RawMouseImpl implements input.RawMouse { } async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - await this._pageProxySession.send('Input.dispatchMouseEvent', { + await progress.race(this._pageProxySession.send('Input.dispatchMouseEvent', { type: 'move', button, buttons: toButtonsMask(buttons), x, y, modifiers: toModifiersMask(modifiers) - }); - progress.throwIfAborted(); + })); } async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - await this._pageProxySession.send('Input.dispatchMouseEvent', { + await progress.race(this._pageProxySession.send('Input.dispatchMouseEvent', { type: 'down', button, buttons: toButtonsMask(buttons), @@ -141,12 +137,11 @@ export class RawMouseImpl implements input.RawMouse { y, modifiers: toModifiersMask(modifiers), clickCount - }); - progress.throwIfAborted(); + })); } async up(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { - await this._pageProxySession.send('Input.dispatchMouseEvent', { + await progress.race(this._pageProxySession.send('Input.dispatchMouseEvent', { type: 'up', button, buttons: toButtonsMask(buttons), @@ -154,8 +149,7 @@ export class RawMouseImpl implements input.RawMouse { y, modifiers: toModifiersMask(modifiers), clickCount - }); - progress.throwIfAborted(); + })); } async wheel(progress: Progress, x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { @@ -163,16 +157,14 @@ export class RawMouseImpl implements input.RawMouse { throw new Error('Mouse wheel is not supported in mobile WebKit'); await this._session!.send('Page.updateScrollingState'); // Wheel events hit the compositor first, so wait one frame for it to be synced. - await this._page!.mainFrame().evaluateExpression(`new Promise(requestAnimationFrame)`, { world: 'utility' }); - progress.throwIfAborted(); - await this._pageProxySession.send('Input.dispatchWheelEvent', { + await progress.race(this._page!.mainFrame().evaluateExpression(`new Promise(requestAnimationFrame)`, { world: 'utility' })); + await progress.race(this._pageProxySession.send('Input.dispatchWheelEvent', { x, y, deltaX, deltaY, modifiers: toModifiersMask(modifiers), - }); - progress.throwIfAborted(); + })); } setPage(page: Page) { @@ -188,11 +180,10 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { } async tap(progress: Progress, x: number, y: number, modifiers: Set) { - await this._pageProxySession.send('Input.dispatchTapEvent', { + await progress.race(this._pageProxySession.send('Input.dispatchTapEvent', { x, y, modifiers: toModifiersMask(modifiers), - }); - progress.throwIfAborted(); + })); } } diff --git a/tests/page/page-add-locator-handler.spec.ts b/tests/page/page-add-locator-handler.spec.ts index f8b1bb6ba720e..605e63da821e6 100644 --- a/tests/page/page-add-locator-handler.spec.ts +++ b/tests/page/page-add-locator-handler.spec.ts @@ -313,7 +313,7 @@ test('should wait for hidden by default 2', async ({ page, server }) => { }); const error = await page.locator('#target').click({ timeout: 3000 }).catch(e => e); expect(await page.evaluate('window.clicked')).toBe(0); - await expect(page.locator('#interstitial')).toBeVisible(); + expect(await page.locator('#interstitial').isVisible()).toBe(true); expect(called).toBe(1); expect(error.message).toContain(`locator handler has finished, waiting for getByRole('button', { name: 'close' }) to be hidden`); }); From fa9d67ebf14eea87238f9a5cd620ea77069144f6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 16 Jun 2025 12:53:31 +0200 Subject: [PATCH 23/71] test: should fill programmatically enabled textarea (#36319) --- tests/page/locator-misc-2.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/page/locator-misc-2.spec.ts b/tests/page/locator-misc-2.spec.ts index 45d1c79da50c7..d3090ad86160d 100644 --- a/tests/page/locator-misc-2.spec.ts +++ b/tests/page/locator-misc-2.spec.ts @@ -184,3 +184,20 @@ it('Locator.locator() and FrameLocator.locator() should accept locator', async ( expect(await divLocator.locator('input').inputValue()).toBe('outer'); expect(await page.frameLocator('iframe').locator(divLocator).locator('input').inputValue()).toBe('inner'); }); + +it('should fill programmatically enabled textarea', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36307' } }, async ({ page }) => { + await page.setContent(` + +
+ +
+ + `); + await page.locator('button').click(); + await page.locator('#text').fill('Hello'); + await expect(page.locator('#text')).toHaveValue('Hello'); +}); From a02722a2f6a16a01bee0619bb08ffc0f4e065175 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:35:53 +0200 Subject: [PATCH 24/71] test: roll stable-test-runner to 1.54.0-alpha-2025-06-16 (#36322) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- .../stable-test-runner/package-lock.json | 46 +++++++++---------- .../stable-test-runner/package.json | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/playwright-test/stable-test-runner/package-lock.json b/tests/playwright-test/stable-test-runner/package-lock.json index 9b1f11a9971e5..31feaa6e26246 100644 --- a/tests/playwright-test/stable-test-runner/package-lock.json +++ b/tests/playwright-test/stable-test-runner/package-lock.json @@ -5,16 +5,16 @@ "packages": { "": { "dependencies": { - "@playwright/test": "1.53.0-beta-1749049851000" + "@playwright/test": "^1.54.0-alpha-2025-06-16" } }, "node_modules/@playwright/test": { - "version": "1.53.0-beta-1749049851000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-beta-1749049851000.tgz", - "integrity": "sha512-6K7D8HLpEOp1LH8NfaM8b6gwen6feLzbXwudn5me5Ev1rGFnh3SzoVLLQK7TPKL62KudXuJyih32oveihNb7Fw==", + "version": "1.54.0-alpha-2025-06-16", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.0-alpha-2025-06-16.tgz", + "integrity": "sha512-TENymjKYtOyPPFWPoJGmpB9ajjRnCjXS/DtUFNX2X1VL97K0Y+Cu15ClS+WmHD9Hpd424x6ov32qPCTdbBGdBQ==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.53.0-beta-1749049851000" + "playwright": "1.54.0-alpha-2025-06-16" }, "bin": { "playwright": "cli.js" @@ -38,12 +38,12 @@ } }, "node_modules/playwright": { - "version": "1.53.0-beta-1749049851000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-beta-1749049851000.tgz", - "integrity": "sha512-zpxtcU6XuiKWG8XwqSjT1c4k8X3VAZF7wHZvuf/9waPWhMe+LftPvS9ohTtIQPFZf4q/CNYdUTu7xs4I1dSrDA==", + "version": "1.54.0-alpha-2025-06-16", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.0-alpha-2025-06-16.tgz", + "integrity": "sha512-2w+M4qAh6XzGkeZMSp6UmuN4xfqyAFGG+zd6CiLsvTT7ZfBVuSLpDxO9N/bTBcp2auk5tmt8173OiwG1kmLXeQ==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.53.0-beta-1749049851000" + "playwright-core": "1.54.0-alpha-2025-06-16" }, "bin": { "playwright": "cli.js" @@ -56,9 +56,9 @@ } }, "node_modules/playwright-core": { - "version": "1.53.0-beta-1749049851000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-beta-1749049851000.tgz", - "integrity": "sha512-hURzp8CoEIwjoDVnsikEaZLhiH91FovOONFu4CjMNCbh47uW7mFW5jR+2Aoju0+M3YQ4XtbdHgIo+42+U3dfSA==", + "version": "1.54.0-alpha-2025-06-16", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.0-alpha-2025-06-16.tgz", + "integrity": "sha512-egCxymKutvP+lWzAQmKfHnomfAyiHpjSseZrTMn52B5hqVjW3BKbvwIU66Z/wY6ZBr6CUoRmiSChUWDd8jH/oA==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -70,11 +70,11 @@ }, "dependencies": { "@playwright/test": { - "version": "1.53.0-beta-1749049851000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-beta-1749049851000.tgz", - "integrity": "sha512-6K7D8HLpEOp1LH8NfaM8b6gwen6feLzbXwudn5me5Ev1rGFnh3SzoVLLQK7TPKL62KudXuJyih32oveihNb7Fw==", + "version": "1.54.0-alpha-2025-06-16", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.0-alpha-2025-06-16.tgz", + "integrity": "sha512-TENymjKYtOyPPFWPoJGmpB9ajjRnCjXS/DtUFNX2X1VL97K0Y+Cu15ClS+WmHD9Hpd424x6ov32qPCTdbBGdBQ==", "requires": { - "playwright": "1.53.0-beta-1749049851000" + "playwright": "1.54.0-alpha-2025-06-16" } }, "fsevents": { @@ -84,18 +84,18 @@ "optional": true }, "playwright": { - "version": "1.53.0-beta-1749049851000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-beta-1749049851000.tgz", - "integrity": "sha512-zpxtcU6XuiKWG8XwqSjT1c4k8X3VAZF7wHZvuf/9waPWhMe+LftPvS9ohTtIQPFZf4q/CNYdUTu7xs4I1dSrDA==", + "version": "1.54.0-alpha-2025-06-16", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.0-alpha-2025-06-16.tgz", + "integrity": "sha512-2w+M4qAh6XzGkeZMSp6UmuN4xfqyAFGG+zd6CiLsvTT7ZfBVuSLpDxO9N/bTBcp2auk5tmt8173OiwG1kmLXeQ==", "requires": { "fsevents": "2.3.2", - "playwright-core": "1.53.0-beta-1749049851000" + "playwright-core": "1.54.0-alpha-2025-06-16" } }, "playwright-core": { - "version": "1.53.0-beta-1749049851000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-beta-1749049851000.tgz", - "integrity": "sha512-hURzp8CoEIwjoDVnsikEaZLhiH91FovOONFu4CjMNCbh47uW7mFW5jR+2Aoju0+M3YQ4XtbdHgIo+42+U3dfSA==" + "version": "1.54.0-alpha-2025-06-16", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.0-alpha-2025-06-16.tgz", + "integrity": "sha512-egCxymKutvP+lWzAQmKfHnomfAyiHpjSseZrTMn52B5hqVjW3BKbvwIU66Z/wY6ZBr6CUoRmiSChUWDd8jH/oA==" } } } diff --git a/tests/playwright-test/stable-test-runner/package.json b/tests/playwright-test/stable-test-runner/package.json index a1d7a2c2bdc7d..fc3aa0e2e21ea 100644 --- a/tests/playwright-test/stable-test-runner/package.json +++ b/tests/playwright-test/stable-test-runner/package.json @@ -1,6 +1,6 @@ { "private": true, "dependencies": { - "@playwright/test": "1.53.0-beta-1749049851000" + "@playwright/test": "^1.54.0-alpha-2025-06-16" } } From 6caf3442fcaf826d821ab6e05f8baf0740ea6165 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Mon, 16 Jun 2025 04:38:15 -0700 Subject: [PATCH 25/71] fix(list): avoid overwriting stdio logs from tests when writing status (#36219) --- docs/src/test-reporters-js.md | 6 +- packages/playwright/src/reporters/base.ts | 25 ++++- packages/playwright/src/reporters/list.ts | 15 +-- tests/playwright-test/reporter-list.spec.ts | 110 ++++++++++++++++++++ 4 files changed, 144 insertions(+), 12 deletions(-) diff --git a/docs/src/test-reporters-js.md b/docs/src/test-reporters-js.md index 294f0466ddb79..e5fe3f526d632 100644 --- a/docs/src/test-reporters-js.md +++ b/docs/src/test-reporters-js.md @@ -102,7 +102,7 @@ List report supports the following configuration options and environment variabl | Environment Variable Name | Reporter Config Option| Description | Default |---|---|---|---| | `PLAYWRIGHT_LIST_PRINT_STEPS` | `printSteps` | Whether to print each step on its own line. | `false` -| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. If a number is specified, it will also be used as the terminal width. | `true` when terminal is in TTY mode, `false` otherwise. +| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. Supports `true`, `1`, `false`, `0`, `[WIDTH]`, and `[WIDTH]x[HEIGHT]`. `[WIDTH]` and `[WIDTH]x[HEIGHT]` specifies the TTY dimensions. | `true` when terminal is in TTY mode, `false` otherwise. | `FORCE_COLOR` | | Whether to produce colored output. | `true` when terminal is in TTY mode, `false` otherwise. @@ -140,7 +140,7 @@ Line report supports the following configuration options and environment variabl | Environment Variable Name | Reporter Config Option| Description | Default |---|---|---|---| -| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. If a number is specified, it will also be used as the terminal width. | `true` when terminal is in TTY mode, `false` otherwise. +| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. Supports `true`, `1`, `false`, `0`, `[WIDTH]`, and `[WIDTH]x[HEIGHT]`. `[WIDTH]` and `[WIDTH]x[HEIGHT]` specifies the TTY dimensions. | `true` when terminal is in TTY mode, `false` otherwise. | `FORCE_COLOR` | | Whether to produce colored output. | `true` when terminal is in TTY mode, `false` otherwise. @@ -182,7 +182,7 @@ Dot report supports the following configuration options and environment variable | Environment Variable Name | Reporter Config Option| Description | Default |---|---|---|---| -| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. If a number is specified, it will also be used as the terminal width. | `true` when terminal is in TTY mode, `false` otherwise. +| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. Supports `true`, `1`, `false`, `0`, `[WIDTH]`, and `[WIDTH]x[HEIGHT]`. `[WIDTH]` and `[WIDTH]x[HEIGHT]` specifies the TTY dimensions. | `true` when terminal is in TTY mode, `false` otherwise. | `FORCE_COLOR` | | Whether to produce colored output. | `true` when terminal is in TTY mode, `false` otherwise. ### HTML reporter diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 5023ae1537b84..ee8640957fd61 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -58,23 +58,39 @@ export type Screen = { colors: Colors; isTTY: boolean; ttyWidth: number; + ttyHeight: number; }; +const DEFAULT_TTY_WIDTH = 100; +const DEFAULT_TTY_HEIGHT = 40; + // Output goes to terminal. export const terminalScreen: Screen = (() => { let isTTY = !!process.stdout.isTTY; let ttyWidth = process.stdout.columns || 0; + let ttyHeight = process.stdout.rows || 0; if (process.env.PLAYWRIGHT_FORCE_TTY === 'false' || process.env.PLAYWRIGHT_FORCE_TTY === '0') { isTTY = false; ttyWidth = 0; + ttyHeight = 0; } else if (process.env.PLAYWRIGHT_FORCE_TTY === 'true' || process.env.PLAYWRIGHT_FORCE_TTY === '1') { isTTY = true; - ttyWidth = process.stdout.columns || 100; + ttyWidth = process.stdout.columns || DEFAULT_TTY_WIDTH; + ttyHeight = process.stdout.rows || DEFAULT_TTY_HEIGHT; } else if (process.env.PLAYWRIGHT_FORCE_TTY) { isTTY = true; - ttyWidth = +process.env.PLAYWRIGHT_FORCE_TTY; + const sizeMatch = process.env.PLAYWRIGHT_FORCE_TTY.match(/^(\d+)x(\d+)$/); + if (sizeMatch) { + ttyWidth = +sizeMatch[1]; + ttyHeight = +sizeMatch[2]; + } else { + ttyWidth = +process.env.PLAYWRIGHT_FORCE_TTY; + ttyHeight = DEFAULT_TTY_HEIGHT; + } if (isNaN(ttyWidth)) - ttyWidth = 100; + ttyWidth = DEFAULT_TTY_WIDTH; + if (isNaN(ttyHeight)) + ttyHeight = DEFAULT_TTY_HEIGHT; } let useColors = isTTY; @@ -89,6 +105,7 @@ export const terminalScreen: Screen = (() => { resolveFiles: 'cwd', isTTY, ttyWidth, + ttyHeight, colors }; })(); @@ -98,6 +115,7 @@ export const nonTerminalScreen: Screen = { colors: terminalScreen.colors, isTTY: false, ttyWidth: 0, + ttyHeight: 0, resolveFiles: 'rootDir', }; @@ -106,6 +124,7 @@ export const internalScreen: Screen = { colors: realColors, isTTY: false, ttyWidth: 0, + ttyHeight: 0, resolveFiles: 'rootDir', }; diff --git a/packages/playwright/src/reporters/list.ts b/packages/playwright/src/reporters/list.ts index 0f87d650d665f..81e398a0192a7 100644 --- a/packages/playwright/src/reporters/list.ts +++ b/packages/playwright/src/reporters/list.ts @@ -102,7 +102,7 @@ class ListReporter extends TerminalReporter { const line = test.title + this.screen.colors.dim(stepSuffix(step)); this._appendLine(line, prefix); } else { - this._updateLine(this._testRows.get(test)!, this.screen.colors.dim(this.formatTestTitle(test, step)) + this._retrySuffix(result), this._testPrefix(testIndex, '')); + this._updateOrAppendLine(this._testRows, test, this.screen.colors.dim(this.formatTestTitle(test, step)) + this._retrySuffix(result), this._testPrefix(testIndex, '')); } } @@ -113,7 +113,7 @@ class ListReporter extends TerminalReporter { const testIndex = this._resultIndex.get(result) || ''; if (!this._printSteps) { if (this.screen.isTTY) - this._updateLine(this._testRows.get(test)!, this.screen.colors.dim(this.formatTestTitle(test, step.parent)) + this._retrySuffix(result), this._testPrefix(testIndex, '')); + this._updateOrAppendLine(this._testRows, test, this.screen.colors.dim(this.formatTestTitle(test, step.parent)) + this._retrySuffix(result), this._testPrefix(testIndex, '')); return; } @@ -127,7 +127,7 @@ class ListReporter extends TerminalReporter { text = title; text += this.screen.colors.dim(` (${milliseconds(step.duration)})`); - this._updateOrAppendLine(this._stepRows.get(step)!, text, prefix); + this._updateOrAppendLine(this._stepRows, step, text, prefix); } private _maybeWriteNewLine() { @@ -196,14 +196,17 @@ class ListReporter extends TerminalReporter { text += this._retrySuffix(result) + this.screen.colors.dim(` (${milliseconds(result.duration)})`); } - this._updateOrAppendLine(this._testRows.get(test)!, text, prefix); + this._updateOrAppendLine(this._testRows, test, text, prefix); } - private _updateOrAppendLine(row: number, text: string, prefix: string) { - if (this.screen.isTTY) { + private _updateOrAppendLine(entityRowNumbers: Map, entity: T, text: string, prefix: string) { + const row = entityRowNumbers.get(entity); + // Only update the line if we assume that the line is still on the screen + if (row !== undefined && this.screen.isTTY && this._lastRow - row < this.screen.ttyHeight) { this._updateLine(row, text, prefix); } else { this._maybeWriteNewLine(); + entityRowNumbers.set(entity, this._lastRow); this._appendLine(text, prefix); } } diff --git a/tests/playwright-test/reporter-list.spec.ts b/tests/playwright-test/reporter-list.spec.ts index 8e6128065ab66..976ce6f2f9140 100644 --- a/tests/playwright-test/reporter-list.spec.ts +++ b/tests/playwright-test/reporter-list.spec.ts @@ -303,6 +303,116 @@ for (const useIntermediateMergeReport of [false, true] as const) { for (let i = 0; i < expected.length; ++i) expect(lines[firstIndex + i]).toContain(expected[i]); }); + + test('should update test status row only when TTY has not scrolled', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('A', async ({}) => { + for (let i = 0; i < 20; ++i) { + console.log('line ' + i); + } + }); + + test('B', async ({}) => { + // Go past end of the screen + for (let i = 20; i < 60; ++i) { + console.log('line ' + i); + } + + // Should create new line + await test.step('First step', async () => { + console.log('step 1'); + }); + + for (let i = 60; i < 80; ++i) { + console.log('line ' + i); + } + + // Should update the new (not original) line + await test.step('Second step', async () => { + console.log('step 2'); + }); + }); + `, + }, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PLAYWRIGHT_FORCE_TTY: '80' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + const expected = [ + '#0 : 1 a.test.ts:3:15 › A', + ]; + for (let i = 0; i < 20; ++i) + expected.push(`line ${i}`); + // Update to initial test status row + expected.push(`#0 : ${POSITIVE_STATUS_MARK} 1 a.test.ts:3:15 › A`); + expected.push(`#21 : 2 a.test.ts:9:15 › B`); + for (let i = 20; i < 60; ++i) + expected.push(`line ${i}`); + expected.push(`#62 : 2 a.test.ts:9:15 › B › First step`); + expected.push(`step 1`); + expected.push(`#62 : 2 a.test.ts:9:15 › B`); + for (let i = 60; i < 80; ++i) + expected.push(`line ${i}`); + expected.push(`#62 : 2 a.test.ts:9:15 › B › Second step`); + expected.push(`step 2`); + expected.push(`#62 : 2 a.test.ts:9:15 › B`); + expected.push(`#62 : ${POSITIVE_STATUS_MARK} 2 a.test.ts:9:15 › B`); + const lines = result.output.split('\n'); + const firstIndex = lines.indexOf(expected[0]); + expect(firstIndex, 'first line should be there').not.toBe(-1); + for (let i = 0; i < expected.length; ++i) + expect(lines[firstIndex + i]).toContain(expected[i]); + }); + + test('should update test status row only within configured TTY height', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('A', async ({}) => { + // No scroll + for (let i = 0; i < 60; ++i) { + console.log('line ' + i); + } + + // Update original line + await test.step('First step', async () => { + console.log('step 1'); + }); + + for (let i = 60; i < 120; ++i) { + console.log('line ' + i); + } + + // Should create new line + await test.step('Second step', async () => { + console.log('step 2'); + }); + }); + `, + }, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PLAYWRIGHT_FORCE_TTY: '80x80' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + const expected = [ + '#0 : 1 a.test.ts:3:15 › A', + ]; + for (let i = 0; i < 60; ++i) + expected.push(`line ${i}`); + // Update to initial test status row + expected.push(`#0 : 1 a.test.ts:3:15 › A › First step`); + expected.push(`step 1`); + expected.push(`#0 : 1 a.test.ts:3:15 › A`); + for (let i = 60; i < 120; ++i) + expected.push(`line ${i}`); + expected.push(`#122 : 1 a.test.ts:3:15 › A › Second step`); + expected.push(`step 2`); + expected.push(`#122 : 1 a.test.ts:3:15 › A`); + expected.push(`#122 : ${POSITIVE_STATUS_MARK} 1 a.test.ts:3:15 › A`); + const lines = result.output.split('\n'); + const firstIndex = lines.indexOf(expected[0]); + expect(firstIndex, 'first line should be there').not.toBe(-1); + for (let i = 0; i < expected.length; ++i) + expect(lines[firstIndex + i]).toContain(expected[i]); + }); }); } From baded7263b64ee244781b0e3222a5dc89d1f5fb4 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Mon, 16 Jun 2025 07:48:44 -0700 Subject: [PATCH 26/71] feat(html): parse and render links in HTML report title (#36326) --- packages/html-reporter/src/headerView.tsx | 4 +-- packages/html-reporter/src/links.tsx | 35 +++++++++++++++++++++ tests/playwright-test/reporter-html.spec.ts | 27 +++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/html-reporter/src/headerView.tsx b/packages/html-reporter/src/headerView.tsx index bd4e178b55139..7aa8542304370 100644 --- a/packages/html-reporter/src/headerView.tsx +++ b/packages/html-reporter/src/headerView.tsx @@ -20,7 +20,7 @@ import './colors.css'; import './common.css'; import './headerView.css'; import * as icons from './icons'; -import { Link, navigate, SearchParamsContext } from './links'; +import { Link, LinkifyText, navigate, SearchParamsContext } from './links'; import { statusIcon } from './statusIcon'; import { filterWithToken } from './filter'; @@ -35,7 +35,7 @@ export const HeaderView: React.FC<{
{rightSuperHeader} - {title &&
{title}
} + {title &&
} ; }; diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index f0054fb501ef9..cb4a5085ff87f 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -114,6 +114,41 @@ export const SearchParamsProvider: React.FunctionComponent{children}; }; +const LINKIFY_REGEX = /https?:\/\/[^\s]+/g; + +export const LinkifyText: React.FunctionComponent<{ text: string }> = ({ text }) => { + const parts = React.useMemo(() => { + const matches = [...text.matchAll(LINKIFY_REGEX)]; + if (matches.length === 0) + return [text]; + const result: Array = []; + let lastIndex = 0; + + for (const match of matches) { + const url = match[0]; + const startIndex = match.index!; + + // Add text before the URL + if (startIndex > lastIndex) + result.push(text.slice(lastIndex, startIndex)); + result.push( + + {url} + + ); + + lastIndex = startIndex + url.length; + } + + // Add any text after the last URL + if (lastIndex < text.length) + result.push(text.slice(lastIndex)); + return result; + }, [text]); + + return <>{parts}; +}; + function downloadFileNameForAttachment(attachment: TestAttachment): string { if (attachment.name.includes('.') || !attachment.path) return attachment.name; diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index b939327f492af..92ee095e2d385 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -435,7 +435,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('div').filter({ hasText: /^Tracestrace$/ }).getByRole('link').first()).toHaveAttribute('href', /trace=(https:\/\/some-url\.com\/)[^/\s]+?\.[^/\s]+/); }); - test('should display title if provided', async ({ runInlineTest, page, showReport }, testInfo) => { + test('should display report title if provided', async ({ runInlineTest, page, showReport }, testInfo) => { const result = await runInlineTest({ 'playwright.config.ts': ` module.exports = { @@ -456,6 +456,31 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('.header-title')).toHaveText('Custom report title'); }); + test('should process URLs as links in report title', async ({ runInlineTest, page, showReport }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + reporter: [['html', { title: 'Custom report title https://playwright.dev separator http://microsoft.com end' }], ['line']] + }; + `, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('fails', async ({ page }) => { + expect(1).toBe(2); + }); + ` + }, {}, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + + await showReport(); + const anchorLocator = page.locator('.header-title a'); + await expect(page.locator('.header-title')).toHaveText('Custom report title https://playwright.dev separator http://microsoft.com end'); + await expect(anchorLocator).toHaveCount(2); + await expect(anchorLocator.nth(0)).toHaveAttribute('href', 'https://playwright.dev'); + await expect(anchorLocator.nth(1)).toHaveAttribute('href', 'http://microsoft.com'); + }); + test('should include stdio', async ({ runInlineTest, page, showReport }) => { const result = await runInlineTest({ 'a.test.js': ` From 1072d14efc3a2dbce25d70b0dc0f516061ba4d9e Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 16 Jun 2025 17:24:02 +0200 Subject: [PATCH 27/71] test: use role based selectors in trace-viewer tests (#36295) Signed-off-by: Max Schmitt Co-authored-by: Dmitry Gozman --- packages/trace-viewer/src/ui/logTab.tsx | 1 + packages/trace-viewer/src/ui/metadataView.tsx | 2 +- packages/trace-viewer/src/ui/networkTab.tsx | 1 + packages/trace-viewer/src/ui/stackTrace.tsx | 1 + packages/web/src/components/gridView.tsx | 1 + packages/web/src/components/listView.tsx | 5 +- tests/config/traceViewerFixtures.ts | 52 ++++++++++--------- tests/library/trace-viewer.spec.ts | 25 +++++---- tests/playwright-test/reporter-html.spec.ts | 6 +-- .../ui-mode-test-network-tab.spec.ts | 6 +-- 10 files changed, 55 insertions(+), 45 deletions(-) diff --git a/packages/trace-viewer/src/ui/logTab.tsx b/packages/trace-viewer/src/ui/logTab.tsx index 4fcb8a6f43bfd..89bf177461474 100644 --- a/packages/trace-viewer/src/ui/logTab.tsx +++ b/packages/trace-viewer/src/ui/logTab.tsx @@ -58,6 +58,7 @@ export const LogTab: React.FunctionComponent<{ return
{entry.time} diff --git a/packages/trace-viewer/src/ui/metadataView.tsx b/packages/trace-viewer/src/ui/metadataView.tsx index 88c2e2bf933fd..9d2e1c207cda4 100644 --- a/packages/trace-viewer/src/ui/metadataView.tsx +++ b/packages/trace-viewer/src/ui/metadataView.tsx @@ -27,7 +27,7 @@ export const MetadataView: React.FunctionComponent<{ const wallTime = model.wallTime !== undefined ? new Date(model.wallTime).toLocaleString(undefined, { timeZoneName: 'short' }) : undefined; - return
+ return
Time
{!!wallTime &&
start time:{wallTime}
}
duration:{msToString(model.endTime - model.startTime)}
diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 65faffd01b4a5..29624df2feb82 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -94,6 +94,7 @@ export const NetworkTab: React.FunctionComponent<{ const grid = setSelectedEntry(item)} diff --git a/packages/trace-viewer/src/ui/stackTrace.tsx b/packages/trace-viewer/src/ui/stackTrace.tsx index 6a0bad1672742..f0e79cd673b53 100644 --- a/packages/trace-viewer/src/ui/stackTrace.tsx +++ b/packages/trace-viewer/src/ui/stackTrace.tsx @@ -29,6 +29,7 @@ export const StackTraceView: React.FunctionComponent<{ const frames = stack || []; return { diff --git a/packages/web/src/components/gridView.tsx b/packages/web/src/components/gridView.tsx index 303de4b8d3600..58942e5527be7 100644 --- a/packages/web/src/components/gridView.tsx +++ b/packages/web/src/components/gridView.tsx @@ -92,6 +92,7 @@ export function GridView(model: GridViewProps) { { return <> diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index 079936c4a12c6..fcf0a461e5ab6 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -27,6 +27,7 @@ export type ListViewProps = { isError?: (item: T, index: number) => boolean, isWarning?: (item: T, index: number) => boolean, isInfo?: (item: T, index: number) => boolean, + ariaLabel?: string, selectedItem?: T, onAccepted?: (item: T, index: number) => void, onSelected?: (item: T, index: number) => void, @@ -56,6 +57,7 @@ export function ListView({ noItemsMessage, dataTestId, notSelectable, + ariaLabel, }: ListViewProps) { const itemListRef = React.useRef(null); const [highlightedItem, setHighlightedItem] = React.useState(); @@ -80,7 +82,7 @@ export function ListView({ itemListRef.current.scrollTop = scrollPositions.get(name) || 0; }, [name]); - return
0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}> + return
0 ? 'list' : undefined} aria-label={ariaLabel}>
({ isError?.(item, index) && 'error', isWarning?.(item, index) && 'warning', isInfo?.(item, index) && 'info')} + aria-selected={selectedItem === item} onClick={() => onSelected?.(item, index)} onMouseEnter={() => setHighlightedItem(item)} onMouseLeave={() => setHighlightedItem(undefined)} diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index eb025c97dcc04..d4d6284c8b0a4 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -43,7 +43,6 @@ class TraceViewerPage { errorMessages: Locator; consoleLineMessages: Locator; consoleStacks: Locator; - stackFrames: Locator; networkRequests: Locator; metadataTab: Locator; snapshotContainer: Locator; @@ -57,74 +56,79 @@ class TraceViewerPage { this.actionTitles = page.locator('.action-title'); this.actionsTree = page.getByTestId('actions-tree'); this.callLines = page.locator('.call-tab .call-line'); - this.logLines = page.getByTestId('log-list').locator('.list-view-entry'); - this.consoleLines = page.locator('.console-line'); + this.logLines = page.getByRole('list', { name: 'Log entries' }).getByRole('listitem'); + this.consoleLines = page.getByRole('tabpanel', { name: 'Console' }).getByRole('listitem'); this.consoleLineMessages = page.locator('.console-line-message'); this.errorMessages = page.locator('.error-message'); this.consoleStacks = page.locator('.console-stack'); - this.stackFrames = page.getByTestId('stack-trace-list').locator('.list-view-entry'); - this.networkRequests = page.getByTestId('network-list').locator('.list-view-entry'); + this.networkRequests = page.getByRole('list', { name: 'Network requests' }).getByRole('listitem'); this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]'); - this.metadataTab = page.getByTestId('metadata-view'); - this.sourceCodeTab = page.getByTestId('source-code'); + this.metadataTab = page.getByRole('tabpanel', { name: 'Metadata' }); + this.sourceCodeTab = page.getByRole('tabpanel', { name: 'Source' }); this.settingsDialog = page.getByTestId('settings-toolbar-dialog'); this.darkModeSetting = page.locator('.setting').getByText('Dark mode'); this.displayCanvasContentSetting = page.locator('.setting').getByText('Display canvas content'); } - async actionIconsText(action: string) { - const entry = await this.page.waitForSelector(`.tree-view-entry:has-text("${action}")`); - await entry.waitForSelector('.action-icon-value:visible'); - return await entry.$$eval('.action-icon-value:visible', ee => ee.map(e => e.textContent)); + stackFrames(options: { selected?: boolean } = {}) { + const entry = this.page.getByRole('list', { name: 'Stack trace' }).getByRole('listitem'); + if (options.selected) + return entry.locator(':scope.selected'); + return entry; } - async actionIcons(action: string) { - return await this.page.waitForSelector(`.tree-view-entry:has-text("${action}") .action-icons`); + actionIconsText(action: string) { + const entry = this.actionsTree.getByRole('treeitem', { name: action }); + return entry.locator('.action-icon-value').filter({ visible: true }); + } + + actionIcons(action: string) { + return this.actionsTree.getByRole('treeitem', { name: action }).locator('.action-icons').filter({ visible: true }); } @step - async expandAction(title: string, ordinal: number = 0) { - await this.actionsTree.locator('.tree-view-entry', { hasText: title }).nth(ordinal).locator('.codicon-chevron-right').click(); + async expandAction(title: string) { + await this.actionsTree.getByRole('treeitem', { name: title }).locator('.codicon-chevron-right').click(); } @step async selectAction(title: string, ordinal: number = 0) { - await this.page.locator(`.action-title:has-text("${title}")`).nth(ordinal).click(); + await this.actionsTree.getByTitle(title).nth(ordinal).click(); } @step async hoverAction(title: string, ordinal: number = 0) { - await this.page.locator(`.action-title:has-text("${title}")`).nth(ordinal).hover(); + await this.actionsTree.getByRole('treeitem', { name: title }).nth(ordinal).hover(); } @step async selectSnapshot(name: string) { - await this.page.click(`.snapshot-tab .tabbed-pane-tab-label:has-text("${name}")`); + await this.page.getByRole('tab', { name }).click(); } async showErrorsTab() { - await this.page.click('text="Errors"'); + await this.page.getByRole('tab', { name: 'Errors' }).click(); } async showConsoleTab() { - await this.page.click('text="Console"'); + await this.page.getByRole('tab', { name: 'Console' }).click(); } async showSourceTab() { - await this.page.click('text="Source"'); + await this.page.getByRole('tab', { name: 'Source' }).click(); } async showNetworkTab() { - await this.page.click('text="Network"'); + await this.page.getByRole('tab', { name: 'Network' }).click(); } async showMetadataTab() { - await this.page.click('text="Metadata"'); + await this.page.getByRole('tab', { name: 'Metadata' }).click(); } async showSettings() { - await this.page.locator('.settings-gear').click(); + await this.page.getByRole('button', { name: 'Settings' }).click(); } @step diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index ff50a454a5e16..16a36e5d59067 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -282,12 +282,12 @@ test('should render console', async ({ showTraceViewer, browserName }) => { await expect(listViews.filter({ hasText: 'Cheers!' })).toHaveClass('list-view-entry'); }); -test('should open console errors on click', async ({ showTraceViewer, browserName }) => { +test('should open console errors on click', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); - expect(await traceViewer.actionIconsText('Evaluate')).toEqual(['2', '1']); - expect(await traceViewer.page.isHidden('.console-tab')).toBeTruthy(); - await (await traceViewer.actionIcons('Evaluate')).click(); - expect(await traceViewer.page.waitForSelector('.console-tab')).toBeTruthy(); + await expect(traceViewer.actionIconsText('Evaluate')).toHaveText(['2', '1']); + await expect(traceViewer.page.getByRole('tabpanel', { name: 'Console' })).toBeHidden(); + await traceViewer.actionIcons('Evaluate').click(); + await traceViewer.page.getByRole('tabpanel', { name: 'Console' }).waitFor(); }); test('should show params and return value', async ({ showTraceViewer }) => { @@ -346,7 +346,7 @@ test('should have correct stack trace', async ({ showTraceViewer }) => { await traceViewer.selectAction('Click'); await traceViewer.showSourceTab(); - await expect(traceViewer.stackFrames).toContainText([ + await expect(traceViewer.stackFrames()).toContainText([ /doClick\s+trace-viewer.spec.ts\s+:\d+/, /recordTrace\s+trace-viewer.spec.ts\s+:\d+/, ], { useInnerText: true }); @@ -977,14 +977,13 @@ test('should highlight expect failure', async ({ page, server, runAndTrace }) => test('should show action source', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); await traceViewer.selectAction('Click'); - const page = traceViewer.page; - await page.click('text=Source'); - await expect(page.locator('.source-line-running')).toContainText('await page.getByText(\'Click\').click()'); - await expect(page.getByTestId('stack-trace-list').locator('.list-view-entry.selected')).toHaveText(/doClick.*trace-viewer\.spec\.ts:[\d]+/); + await traceViewer.showSourceTab(); + await expect(traceViewer.page.locator('.source-line-running')).toContainText('await page.getByText(\'Click\').click()'); + await expect(traceViewer.stackFrames({ selected: true })).toHaveText(/doClick.*trace-viewer\.spec\.ts:[\d]+/); await traceViewer.hoverAction('Wait for navigation'); - await expect(page.locator('.source-line-running')).toContainText('page.waitForNavigation()'); + await expect(traceViewer.page.locator('.source-line-running')).toContainText('page.waitForNavigation()'); }); test('should follow redirects', async ({ page, runAndTrace, server, asset }) => { @@ -1015,7 +1014,7 @@ test('should follow redirects', async ({ page, runAndTrace, server, asset }) => test('should include metainfo', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); - await traceViewer.page.locator('text=Metadata').click(); + await traceViewer.page.getByRole('tab', { name: 'Metadata' }).click(); const callLine = traceViewer.metadataTab.locator('.call-line'); await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/); await expect(callLine.getByText('duration')).toHaveText(/duration:[\dms]+/); @@ -1065,7 +1064,7 @@ test('should open two trace files', async ({ context, page, request, server, sho /Fetch "\/one-style\.css"/, ]); - await traceViewer.page.locator('text=Metadata').click(); + await traceViewer.page.getByRole('tab', { name: 'Metadata' }).click(); const callLine = traceViewer.page.locator('.call-line'); // Should get metadata from the context trace await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 92ee095e2d385..fc57ad7f7314b 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -543,7 +543,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { await page.getByRole('link', { name: 'passes' }).click(); await page.click('img'); await page.click('.action-title >> text=EVALUATE'); - await page.click('text=Source'); + await page.getByRole('tab', { name: 'Source' }).click(); await expect(page.locator('.CodeMirror-line')).toContainText([ /import.*test/, @@ -551,10 +551,10 @@ for (const useIntermediateMergeReport of [true, false] as const) { ]); await expect(page.locator('.source-line-running')).toContainText('page.evaluate'); - await expect(page.getByTestId('stack-trace-list')).toContainText([ + await expect(page.getByRole('list', { name: 'Stack trace' }).getByRole('listitem')).toContainText([ /a.test.js:[\d]+/, ]); - await expect(page.getByTestId('stack-trace-list').locator('.list-view-entry.selected')).toContainText('a.test.js'); + await expect(page.getByRole('list', { name: 'Stack trace' }).getByRole('listitem').and(page.locator('.selected'))).toContainText('a.test.js'); }); test('should not show stack trace', async ({ runInlineTest, page, showReport }) => { diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index 30c63494c1f2a..10810d1616184 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -32,7 +32,7 @@ test('should filter network requests by resource type', async ({ runUITest, serv await page.getByText('network tab test').dblclick(); await page.getByText('Network', { exact: true }).click(); - const networkItems = page.getByTestId('network-list').getByRole('listitem'); + const networkItems = page.getByRole('list', { name: 'Network requests' }).getByRole('listitem'); await page.getByText('JS', { exact: true }).click(); await expect(networkItems).toHaveCount(1); @@ -73,7 +73,7 @@ test('should filter network requests by url', async ({ runUITest, server }) => { await page.getByText('network tab test').dblclick(); await page.getByText('Network', { exact: true }).click(); - const networkItems = page.getByTestId('network-list').getByRole('listitem'); + const networkItems = page.getByRole('list', { name: 'Network requests' }).getByRole('listitem'); await page.getByPlaceholder('Filter network').fill('script.'); await expect(networkItems).toHaveCount(1); @@ -198,5 +198,5 @@ test('should not duplicate network entries from beforeAll', { await page.getByText('first test').dblclick(); await page.getByText('Network', { exact: true }).click(); - await expect(page.getByTestId('network-list').getByText('empty.html')).toHaveCount(1); + await expect(page.getByRole('list', { name: 'Network requests' }).getByText('empty.html')).toHaveCount(1); }); From 764dedac66536d08bdca3f2c47311bafeb34dac1 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 16 Jun 2025 17:27:38 +0200 Subject: [PATCH 28/71] chore: hide locator(':root') in Steps for toHaveTitle/URL (#36213) --- packages/injected/src/injectedScript.ts | 14 ++++++++++---- packages/playwright-core/src/client/frame.ts | 9 +++++++++ packages/playwright-core/src/client/locator.ts | 11 ++++------- .../playwright-core/src/protocol/validator.ts | 2 +- packages/playwright-core/src/server/frames.ts | 11 ++++++----- packages/playwright/src/matchers/matchers.ts | 18 ++++++++++-------- .../playwright/src/matchers/toMatchText.ts | 10 +++++----- packages/protocol/src/channels.d.ts | 3 ++- packages/protocol/src/protocol.yml | 2 +- tests/library/trace-viewer.spec.ts | 8 +++++++- tests/page/expect-misc.spec.ts | 4 ++-- tests/playwright-test/expect.spec.ts | 2 +- 12 files changed, 58 insertions(+), 36 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 5155204e2b1b0..b18251f836bb5 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1348,6 +1348,16 @@ export class InjectedScript { // expect(locator).not.toBeInViewport() passes when there is no element. if (options.isNot && options.expression === 'to.be.in.viewport') return { matches: false }; + if (options.expression === 'to.have.title' && options?.expectedText?.[0]) { + const matcher = new ExpectedTextMatcher(options.expectedText[0]); + const received = this.document.title; + return { received, matches: matcher.matches(received) }; + } + if (options.expression === 'to.have.url' && options?.expectedText?.[0]) { + const matcher = new ExpectedTextMatcher(options.expectedText[0]); + const received = this.document.location.href; + return { received, matches: matcher.matches(received) }; + } // When none of the above applies, expect does not match. return { matches: options.isNot, missingReceived: true }; } @@ -1497,10 +1507,6 @@ export class InjectedScript { received = getElementAccessibleErrorMessage(element); } else if (expression === 'to.have.role') { received = getAriaRole(element) || ''; - } else if (expression === 'to.have.title') { - received = this.document.title; - } else if (expression === 'to.have.url') { - received = this.document.location.href; } else if (expression === 'to.have.value') { element = this.retarget(element, 'follow-label')!; if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT') diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 32fa6b0997240..780d3680eed40 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -460,6 +460,15 @@ export class Frame extends ChannelOwner implements api.Fr async title(): Promise { return (await this._channel.title()).value; } + + async _expect(expression: string, options: Omit): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { + const params: channels.FrameExpectParams = { expression, ...options, isNot: !!options.isNot }; + params.expectedValue = serializeArgument(options.expectedValue); + const result = (await this._channel.expect(params)); + if (result.received !== undefined) + result.received = parseResult(result.received); + return result; + } } export function verifyLoadState(name: string, waitUntil: LifecycleEvent): LifecycleEvent { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 9e759c2f34220..ea780c2fbe34d 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -15,7 +15,6 @@ */ import { ElementHandle } from './elementHandle'; -import { parseResult, serializeArgument } from './jsHandle'; import { asLocator } from '../utils/isomorphic/locatorGenerators'; import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils'; import { escapeForTextSelector } from '../utils/isomorphic/stringUtils'; @@ -374,12 +373,10 @@ export class Locator implements api.Locator { } async _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { - const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; - params.expectedValue = serializeArgument(options.expectedValue); - const result = (await this._frame._channel.expect(params)); - if (result.received !== undefined) - result.received = parseResult(result.received); - return result; + return this._frame._expect(expression, { + ...options, + selector: this._selector, + }); } private _inspect() { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 71903879cec9f..7e9073a25166d 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1882,7 +1882,7 @@ scheme.FrameWaitForSelectorResult = tObject({ element: tOptional(tChannel(['ElementHandle'])), }); scheme.FrameExpectParams = tObject({ - selector: tString, + selector: tOptional(tString), expression: tString, expressionArg: tOptional(tAny), expectedText: tOptional(tArray(tType('ExpectedTextValue'))), diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 11981950fc1dc..2ed8ca96b1b33 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1375,7 +1375,7 @@ export class Frame extends SdkObject { }, options.timeout); } - async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { + async expect(metadata: CallMetadata, selector: string | undefined, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { const result = await this._expectImpl(metadata, selector, options); // Library mode special case for the expect errors which are return values, not exceptions. if (result.matches === options.isNot) @@ -1383,7 +1383,7 @@ export class Frame extends SdkObject { return result; } - private async _expectImpl(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { + private async _expectImpl(metadata: CallMetadata, selector: string | undefined, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { const lastIntermediateResult: { received?: any, isSet: boolean } = { isSet: false }; try { let timeout = options.timeout; @@ -1392,7 +1392,8 @@ export class Frame extends SdkObject { // Step 1: perform locator handlers checkpoint with a specified timeout. await (new ProgressController(metadata, this)).run(async progress => { progress.log(`${renderTitleForCall(metadata)}${timeout ? ` with timeout ${timeout}ms` : ''}`); - progress.log(`waiting for ${this._asLocator(selector)}`); + if (selector) + progress.log(`waiting for ${this._asLocator(selector)}`); await this._page.performActionPreChecks(progress); }, timeout); @@ -1445,8 +1446,8 @@ export class Frame extends SdkObject { } } - private async _expectInternal(progress: Progress, selector: string, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) { - const selectorInFrame = await this.selectors.resolveFrameForSelector(selector, { strict: true }); + private async _expectInternal(progress: Progress, selector: string | undefined, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) { + const selectorInFrame = selector ? await this.selectors.resolveFrameForSelector(selector, { strict: true }) : undefined; progress.throwIfAborted(); const { frame, info } = selectorInFrame || { frame: this, info: undefined }; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 5dbd98ee7753b..c3097ff604385 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -28,7 +28,7 @@ import { TestInfoImpl } from '../worker/testInfo'; import type { ExpectMatcherState } from '../../types/test'; import type { TestStepInfoImpl } from '../worker/testInfo'; -import type { APIResponse, Locator, Page } from 'playwright-core'; +import type { APIResponse, Locator, Frame, Page } from 'playwright-core'; import type { FrameExpectParams } from 'playwright-core/lib/client/types'; export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl }; @@ -37,6 +37,10 @@ export interface LocatorEx extends Locator { _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; } +export interface FrameEx extends Frame { + _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; +} + interface APIResponseEx extends APIResponse { _fetchLog(): Promise; } @@ -401,10 +405,9 @@ export function toHaveTitle( expected: string | RegExp, options: { timeout?: number } = {}, ) { - const locator = page.locator(':root') as LocatorEx; - return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveTitle', page, 'Page', async (isNot, timeout) => { const expectedText = serializeExpectedTextValues([expected], { normalizeWhiteSpace: true }); - return await locator._expect('to.have.title', { expectedText, isNot, timeout }); + return await (page.mainFrame() as FrameEx)._expect('to.have.title', { expectedText, isNot, timeout }); }, expected, { receiverLabel: 'page', ...options }); } @@ -420,11 +423,10 @@ export function toHaveURL( const baseURL = (page.context() as any)._options.baseURL; expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected; - const locator = page.locator(':root') as LocatorEx; - return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (isNot, timeout) => { + return toMatchText.call(this, 'toHaveURL', page, 'Page', async (isNot, timeout) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase }); - return await locator._expect('to.have.url', { expectedText, isNot, timeout }); - }, expected, options); + return await (page.mainFrame() as FrameEx)._expect('to.have.url', { expectedText, isNot, timeout }); + }, expected, { receiverLabel: 'page', ...options }); } export async function toBeOK( diff --git a/packages/playwright/src/matchers/toMatchText.ts b/packages/playwright/src/matchers/toMatchText.ts index 6aa1c111f8bd3..45fe4fb996f81 100644 --- a/packages/playwright/src/matchers/toMatchText.ts +++ b/packages/playwright/src/matchers/toMatchText.ts @@ -27,13 +27,13 @@ import { EXPECTED_COLOR } from '../common/expectBundle'; import type { MatcherResult } from './matcherHint'; import type { ExpectMatcherState } from '../../types/test'; -import type { Locator } from 'playwright-core'; +import type { Page, Locator } from 'playwright-core'; export async function toMatchText( this: ExpectMatcherState, matcherName: string, - receiver: Locator, - receiverType: string, + receiver: Locator | Page, + receiverType: 'Locator' | 'Page', query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, received?: string, log?: string[], timedOut?: boolean }>, expected: string | RegExp, options: { timeout?: number, matchSubstring?: boolean, receiverLabel?: string } = {}, @@ -51,7 +51,7 @@ export async function toMatchText( ) { // Same format as jest's matcherErrorMessage throw new Error([ - matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions), + matcherHint(this, receiverType === 'Locator' ? receiver as Locator : undefined, matcherName, options.receiverLabel ?? receiver, expected, matcherOptions), `${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a string or regular expression`, this.utils.printWithType('Expected', expected, this.utils.printExpected) ].join('\n\n')); @@ -71,7 +71,7 @@ export async function toMatchText( const stringSubstring = options.matchSubstring ? 'substring' : 'string'; const receivedString = received || ''; - const messagePrefix = matcherHint(this, receiver, matcherName, options.receiverLabel ?? 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); + const messagePrefix = matcherHint(this, receiverType === 'Locator' ? receiver as Locator : undefined, matcherName, options.receiverLabel ?? 'locator', undefined, matcherOptions, timedOut ? timeout : undefined); const notFound = received === kNoElementsFoundError; let printedReceived: string | undefined; diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 90ab1680d9ddf..1afeaeec8b835 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -3238,7 +3238,7 @@ export type FrameWaitForSelectorResult = { element?: ElementHandleChannel, }; export type FrameExpectParams = { - selector: string, + selector?: string, expression: string, expressionArg?: any, expectedText?: ExpectedTextValue[], @@ -3249,6 +3249,7 @@ export type FrameExpectParams = { timeout: number, }; export type FrameExpectOptions = { + selector?: string, expressionArg?: any, expectedText?: ExpectedTextValue[], expectedNumber?: number, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 8e85b73422594..849bc91305b4a 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2659,7 +2659,7 @@ Frame: expect: title: Expect "{expression}" parameters: - selector: string + selector: string? expression: string expressionArg: json? expectedText: diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 16a36e5d59067..71dc0c70f6dc5 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -40,7 +40,9 @@ test.beforeAll(async function recordTrace({ browser, browserName, browserType, s }); await context.tracing.start({ name: 'test', screenshots: true, snapshots: true, sources: true }); const page = await context.newPage(); - await page.goto(`data:text/html,Hello world`); + await page.goto(`data:text/html,HelloHello world`); + await expect(page).toHaveTitle('Hello'); + await expect(page).toHaveURL('data:text/html,HelloHello world'); await page.setContent(''); await expect(page.locator('button')).toHaveText('Click'); await expect(page.getByTestId('amazing-btn')).toBeHidden(); @@ -151,6 +153,8 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => { await expect(traceViewer.actionTitles).toHaveText([ /Create page/, /Navigate to "data:"/, + /^Expect "toHaveTitle"[\d]+ms$/, + /^Expect "toHaveURL"[\d]+ms$/, /Set content/, /toHaveText.*locator/, /toBeHidden.*getByTestId/, @@ -1826,6 +1830,8 @@ test('should render blob trace received from message', async ({ showTraceViewer await expect(traceViewer.actionTitles).toHaveText([ /Create page/, /Navigate to "data:"/, + /toHaveTitle/, + /toHaveURL/, /Set content/, /toHaveText.*locator/, /toBeHidden.*getByTestId/, diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index bf963f36fe6d1..37cbdb1c0cc55 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -296,7 +296,7 @@ test.describe('toHaveURL', () => { test('fail string', async ({ page }) => { await page.goto('data:text/html,
A
'); const error = await expect(page).toHaveURL('wrong', { timeout: 1000 }).catch(e => e); - expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(locator).toHaveURL(expected)'); + expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)'); expect(stripVTControlCharacters(error.message)).toContain('Expected string: "wrong"\nReceived string: "data:text/html,
A
"'); }); @@ -304,7 +304,7 @@ test.describe('toHaveURL', () => { await page.goto('data:text/html,
A
'); // @ts-expect-error const error = await expect(page).toHaveURL({}).catch(e => e); - expect(stripVTControlCharacters(error.message)).toContain(`expect(locator(':root')).toHaveURL([object Object])`); + expect(stripVTControlCharacters(error.message)).toContain(`expect(page).toHaveURL([object Object])`); expect(stripVTControlCharacters(error.message)).toContain('Expected has type: object\nExpected has value: {}'); }); diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index 187746c607659..45698ce7d6f5d 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -548,7 +548,7 @@ test('should respect expect.timeout', async ({ runInlineTest }) => { test('timeout', async ({ page }) => { await page.goto('data:text/html,
A
'); const error = await expect(page).toHaveURL('data:text/html,
B
').catch(e => e); - expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(locator).toHaveURL(expected)'); + expect(stripVTControlCharacters(error.message)).toContain('Timed out 1000ms waiting for expect(page).toHaveURL(expected)'); expect(error.message).toContain('data:text/html,
'); }); `, From 90c6921318b29ebdb1ec805485901b43d4714b7a Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 16 Jun 2025 18:01:50 +0200 Subject: [PATCH 29/71] test: add failing oopif cdp bug (#36329) Co-authored-by: Max Schmitt --- tests/library/chromium/oopif.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/library/chromium/oopif.spec.ts b/tests/library/chromium/oopif.spec.ts index 54cd3e1a46481..c062239a609e1 100644 --- a/tests/library/chromium/oopif.spec.ts +++ b/tests/library/chromium/oopif.spec.ts @@ -371,6 +371,23 @@ it('should intercept response body from oopif', async function({ page, browser, expect(await response.text()).toBeTruthy(); }); +it.fail('should allow to re-connect to OOPIFs with CDP when iframes were there already', async ({ browserType, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36095' }); + + const cdpPort = 10123 + it.info().parallelIndex * 4; + const hostBrowser = await browserType.launch({ cdpPort } as any); + const hostPage = await hostBrowser.newPage(); + await hostPage.goto(server.PREFIX + '/dynamic-oopif.html'); + + const browser = await browserType.connectOverCDP(`http://localhost:${cdpPort}`); + const page = browser.contexts()[0].pages()[0]; + // can be fixed by reloading first + // await page.reload(); + expect(page.frames().length).toBe(2); + await assertOOPIFCount(browser, 1); + expect(await page.frames()[1].evaluate(() => '' + location.href)).toBe(server.CROSS_PROCESS_PREFIX + '/grid.html'); +}); + async function assertOOPIFCount(browser: Browser, count: number) { if (browser.browserType().name() !== 'chromium') return; From 114c9c045213888179b11f0d5a1e2fb87ebc1bd8 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Mon, 16 Jun 2025 09:15:06 -0700 Subject: [PATCH 30/71] chore(html): revert baded72 and use existing linkifyText (#36328) --- packages/html-reporter/src/headerView.tsx | 5 ++-- packages/html-reporter/src/links.tsx | 35 ----------------------- 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/packages/html-reporter/src/headerView.tsx b/packages/html-reporter/src/headerView.tsx index 7aa8542304370..0b80590212b8d 100644 --- a/packages/html-reporter/src/headerView.tsx +++ b/packages/html-reporter/src/headerView.tsx @@ -20,9 +20,10 @@ import './colors.css'; import './common.css'; import './headerView.css'; import * as icons from './icons'; -import { Link, LinkifyText, navigate, SearchParamsContext } from './links'; +import { Link, navigate, SearchParamsContext } from './links'; import { statusIcon } from './statusIcon'; import { filterWithToken } from './filter'; +import { linkifyText } from '@web/renderUtils'; export const HeaderView: React.FC<{ title: string | undefined, @@ -35,7 +36,7 @@ export const HeaderView: React.FC<{
{rightSuperHeader}
- {title &&
} + {title &&
{linkifyText(title)}
}
; }; diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index cb4a5085ff87f..f0054fb501ef9 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -114,41 +114,6 @@ export const SearchParamsProvider: React.FunctionComponent{children}; }; -const LINKIFY_REGEX = /https?:\/\/[^\s]+/g; - -export const LinkifyText: React.FunctionComponent<{ text: string }> = ({ text }) => { - const parts = React.useMemo(() => { - const matches = [...text.matchAll(LINKIFY_REGEX)]; - if (matches.length === 0) - return [text]; - const result: Array = []; - let lastIndex = 0; - - for (const match of matches) { - const url = match[0]; - const startIndex = match.index!; - - // Add text before the URL - if (startIndex > lastIndex) - result.push(text.slice(lastIndex, startIndex)); - result.push( - - {url} - - ); - - lastIndex = startIndex + url.length; - } - - // Add any text after the last URL - if (lastIndex < text.length) - result.push(text.slice(lastIndex)); - return result; - }, [text]); - - return <>{parts}; -}; - function downloadFileNameForAttachment(attachment: TestAttachment): string { if (attachment.name.includes('.') || !attachment.path) return attachment.name; From ada23721867168003468cc7d81ce97121b60a443 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 14:06:19 +0200 Subject: [PATCH 31/71] feat(webkit): roll to r2185 (#36335) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 24f68d61ae241..05082cd0a9fea 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "2184", + "revision": "2185", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", From a439191551dd0365cae87bea1023be1c61d74dfe Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 17 Jun 2025 14:09:20 +0200 Subject: [PATCH 32/71] chore: move HTTP server behaviour from WSServer to PlaywrightServer (#36334) --- .../src/remote/playwrightServer.ts | 11 ++++++++ .../src/server/utils/wsServer.ts | 25 ++++++------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 9a564c4252c83..e823f8ce87935 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -57,6 +57,17 @@ export class PlaywrightServer { const reuseBrowserSemaphore = new Semaphore(1); this._wsServer = new WSServer({ + onRequest: (request, response) => { + if (request.method === 'GET' && request.url === '/json') { + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ + wsEndpointPath: this._options.path, + })); + return; + } + response.end('Running'); + }, + onUpgrade: (request, socket) => { const uaError = userAgentVersionMatchesErrorMessage(request.headers['user-agent'] || ''); if (uaError) diff --git a/packages/playwright-core/src/server/utils/wsServer.ts b/packages/playwright-core/src/server/utils/wsServer.ts index 68362f14de280..5251e52d3b467 100644 --- a/packages/playwright-core/src/server/utils/wsServer.ts +++ b/packages/playwright-core/src/server/utils/wsServer.ts @@ -42,10 +42,11 @@ export type WSConnection = { }; export type WSServerDelegate = { - onHeaders?: (headers: string[]) => void; - onUpgrade?: (request: http.IncomingMessage, socket: stream.Duplex) => { error: string } | undefined; + onRequest: (request: http.IncomingMessage, response: http.ServerResponse) => void; + onHeaders: (headers: string[]) => void; + onUpgrade: (request: http.IncomingMessage, socket: stream.Duplex) => { error: string } | undefined; onConnection: (request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) => WSConnection; - onClose?(): Promise; + onClose(): Promise; }; export class WSServer { @@ -60,16 +61,7 @@ export class WSServer { async listen(port: number = 0, hostname: string | undefined, path: string): Promise { debugLogger.log('server', `Server started at ${new Date()}`); - const server = createHttpServer((request: http.IncomingMessage, response: http.ServerResponse) => { - if (request.method === 'GET' && request.url === '/json') { - response.setHeader('Content-Type', 'application/json'); - response.end(JSON.stringify({ - wsEndpointPath: path, - })); - return; - } - response.end('Running'); - }); + const server = createHttpServer(this._delegate.onRequest); server.on('error', error => debugLogger.log('server', String(error))); this.server = server; @@ -92,8 +84,7 @@ export class WSServer { perMessageDeflate, }); - if (this._delegate.onHeaders) - this._wsServer.on('headers', headers => this._delegate.onHeaders!(headers)); + this._wsServer.on('headers', headers => this._delegate.onHeaders(headers)); server.on('upgrade', (request, socket, head) => { const pathname = new URL('http://localhost' + request.url!).pathname; @@ -102,13 +93,13 @@ export class WSServer { socket.destroy(); return; } - const upgradeResult = this._delegate.onUpgrade?.(request, socket); + const upgradeResult = this._delegate.onUpgrade(request, socket); if (upgradeResult) { socket.write(upgradeResult.error); socket.destroy(); return; } - this._wsServer?.handleUpgrade(request, socket, head, ws => this._wsServer?.emit('connection', ws, request)); + this._wsServer!.handleUpgrade(request, socket, head, ws => this._wsServer!.emit('connection', ws, request)); }); this._wsServer.on('connection', (ws, request) => { From 433491179cf6aff6cb8952a766f45e92be8a8e1a Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 17:49:18 +0200 Subject: [PATCH 33/71] feat(chromium-tip-of-tree): roll to r1341 (#36338) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 05082cd0a9fea..3fd4b2c87a8dd 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,15 +15,15 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1340", + "revision": "1341", "installByDefault": false, - "browserVersion": "139.0.7234.0" + "browserVersion": "139.0.7244.0" }, { "name": "chromium-tip-of-tree-headless-shell", - "revision": "1340", + "revision": "1341", "installByDefault": false, - "browserVersion": "139.0.7234.0" + "browserVersion": "139.0.7244.0" }, { "name": "firefox", From 7e87033297f0d9c22727c10ccd9c3d82c82f71a3 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 07:46:12 +0200 Subject: [PATCH 34/71] feat(firefox): roll to r1488 (#36340) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 3fd4b2c87a8dd..3c99710e9c0e6 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "firefox", - "revision": "1487", + "revision": "1488", "installByDefault": true, "browserVersion": "139.0" }, From a7ff65cb8b24eb7b1560e78799517f42d62d7b20 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 07:46:39 +0200 Subject: [PATCH 35/71] feat(firefox-beta): roll to r1484 (#36341) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 3c99710e9c0e6..ae4ec5d7beb55 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -33,7 +33,7 @@ }, { "name": "firefox-beta", - "revision": "1483", + "revision": "1484", "installByDefault": false, "browserVersion": "140.0b7" }, From 8fcf838c37dd1eab34fdadadb83bf4a22429072b Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 18 Jun 2025 08:28:33 +0100 Subject: [PATCH 36/71] chore: move some playwright-wide options to be per browser (#36342) --- .../playwright-core/src/browserServerImpl.ts | 8 +------ .../src/remote/playwrightConnection.ts | 23 +++++++++++-------- .../src/server/bidi/bidiChromium.ts | 4 ++-- .../playwright-core/src/server/browser.ts | 6 +++++ .../playwright-core/src/server/browserType.ts | 6 ++--- .../src/server/chromium/chromium.ts | 4 ++-- .../src/server/chromium/crPage.ts | 2 +- packages/playwright-core/src/server/dom.ts | 2 +- .../src/server/frameSelectors.ts | 2 +- packages/playwright-core/src/server/frames.ts | 2 +- packages/playwright-core/src/server/page.ts | 4 ++-- .../playwright-core/src/server/playwright.ts | 1 - .../src/server/recorder/contextRecorder.ts | 2 +- .../src/server/recorder/recorderApp.ts | 2 +- .../src/server/trace/recorder/tracing.ts | 8 +++++-- .../src/server/trace/viewer/traceViewer.ts | 1 - packages/playwright-core/src/server/types.ts | 1 + 17 files changed, 43 insertions(+), 35 deletions(-) diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index 34881f133fc94..ee20e3a102d01 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { SocksProxy } from './server/utils/socksProxy'; import { PlaywrightServer } from './remote/playwrightServer'; import { helper } from './server/helper'; import { serverSideCallMetadata } from './server/instrumentation'; @@ -41,10 +40,6 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { async launchServer(options: LaunchServerOptions & { _sharedBrowser?: boolean, _userDataDir?: string } = {}): Promise { const playwright = createPlaywright({ sdkLanguage: 'javascript', isServer: true }); - // TODO: enable socks proxy once ipv6 is supported. - const socksProxy = false ? new SocksProxy() : undefined; - playwright.options.socksProxyPort = await socksProxy?.listen(0); - // 1. Pre-launch the browser const metadata = serverSideCallMetadata(); const validatorContext = { @@ -83,7 +78,7 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { const path = options.wsPath ? (options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`) : `/${createGuid()}`; // 2. Start the server - const server = new PlaywrightServer({ mode: options._sharedBrowser ? 'launchServerShared' : 'launchServer', path, maxConnections: Infinity, preLaunchedBrowser: browser, preLaunchedSocksProxy: socksProxy }); + const server = new PlaywrightServer({ mode: options._sharedBrowser ? 'launchServerShared' : 'launchServer', path, maxConnections: Infinity, preLaunchedBrowser: browser }); const wsEndpoint = await server.listen(options.port, options.host); // 3. Return the BrowserServer interface @@ -96,7 +91,6 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { (browserServer as any)._disconnectForTest = () => server.close(); (browserServer as any)._userDataDirForTest = (browser as any)._userDataDirForTest; browser.options.browserProcess.onclose = (exitCode, signal) => { - socksProxy?.close().catch(() => {}); server.close(); browserServer.emit('close', exitCode, signal); }; diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 2f9abfbf01154..94fe34e7aa5b6 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -107,9 +107,9 @@ export class PlaywrightConnection { this._root = new RootDispatcher(this._dispatcherConnection, async (scope, options) => { await startProfiling(); if (clientType === 'reuse-browser') - return await this._initReuseBrowsersMode(scope); + return await this._initReuseBrowsersMode(scope, options); if (clientType === 'pre-launched-browser-or-android') - return this._preLaunched.browser ? await this._initPreLaunchedBrowserMode(scope) : await this._initPreLaunchedAndroidMode(scope); + return this._preLaunched.browser ? await this._initPreLaunchedBrowserMode(scope, options) : await this._initPreLaunchedAndroidMode(scope); if (clientType === 'launch-browser') return await this._initLaunchBrowserMode(scope, options); throw new Error('Unsupported client type: ' + clientType); @@ -120,7 +120,7 @@ export class PlaywrightConnection { debugLogger.log('server', `[${this._id}] engaged launch mode for "${this._options.browserName}"`); const playwright = createPlaywright({ sdkLanguage: options.sdkLanguage, isServer: true }); - const ownedSocksProxy = await this._createOwnedSocksProxy(playwright); + const ownedSocksProxy = await this._createOwnedSocksProxy(); let browserName = this._options.browserName; if ('bidi' === browserName) { if (this._options.launchOptions?.channel?.toLocaleLowerCase().includes('firefox')) @@ -129,6 +129,7 @@ export class PlaywrightConnection { browserName = 'bidiChromium'; } const browser = await playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); + browser.options.sdkLanguage = options.sdkLanguage; this._cleanups.push(async () => { for (const browser of playwright.allBrowsers()) @@ -142,7 +143,7 @@ export class PlaywrightConnection { return new PlaywrightDispatcher(scope, playwright, { socksProxy: ownedSocksProxy, preLaunchedBrowser: browser }); } - private async _initPreLaunchedBrowserMode(scope: RootDispatcher) { + private async _initPreLaunchedBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { debugLogger.log('server', `[${this._id}] engaged pre-launched (browser) mode`); const playwright = this._preLaunched.playwright!; @@ -150,6 +151,7 @@ export class PlaywrightConnection { this._preLaunched.socksProxy?.setPattern(this._options.socksProxyPattern); const browser = this._preLaunched.browser!; + browser.options.sdkLanguage = options.sdkLanguage; browser.on(Browser.Events.Disconnected, () => { // Underlying browser did close for some reason - force disconnect the client. this.close({ code: 1001, reason: 'Browser closed' }); @@ -189,7 +191,7 @@ export class PlaywrightConnection { return new DebugControllerDispatcher(this._dispatcherConnection, playwright.debugController); } - private async _initReuseBrowsersMode(scope: RootDispatcher) { + private async _initReuseBrowsersMode(scope: RootDispatcher, options: channels.RootInitializeParams) { // Note: reuse browser mode does not support socks proxy, because // clients come and go, while the browser stays the same. @@ -222,6 +224,7 @@ export class PlaywrightConnection { this.close({ code: 1001, reason: 'Browser closed' }); }); } + browser.options.sdkLanguage = options.sdkLanguage; this._cleanups.push(async () => { // Don't close the pages so that user could debug them, @@ -242,13 +245,15 @@ export class PlaywrightConnection { return playwrightDispatcher; } - private async _createOwnedSocksProxy(playwright: Playwright): Promise { - if (!this._options.socksProxyPattern) + private async _createOwnedSocksProxy(): Promise { + if (!this._options.socksProxyPattern) { + this._options.launchOptions.socksProxyPort = undefined; return; + } const socksProxy = new SocksProxy(); socksProxy.setPattern(this._options.socksProxyPattern); - playwright.options.socksProxyPort = await socksProxy.listen(0); - debugLogger.log('server', `[${this._id}] started socks proxy on port ${playwright.options.socksProxyPort}`); + this._options.launchOptions.socksProxyPort = await socksProxy.listen(0); + debugLogger.log('server', `[${this._id}] started socks proxy on port ${this._options.launchOptions.socksProxyPort}`); this._cleanups.push(() => socksProxy.close()); return socksProxy; } diff --git a/packages/playwright-core/src/server/bidi/bidiChromium.ts b/packages/playwright-core/src/server/bidi/bidiChromium.ts index 53fc05cb9ce93..4347d0bcd62a1 100644 --- a/packages/playwright-core/src/server/bidi/bidiChromium.ts +++ b/packages/playwright-core/src/server/bidi/bidiChromium.ts @@ -144,14 +144,14 @@ export class BidiChromium extends BrowserType { const proxyURL = new URL(proxy.server); const isSocks = proxyURL.protocol === 'socks5:'; // https://www.chromium.org/developers/design-documents/network-settings - if (isSocks && !this.attribution.playwright.options.socksProxyPort) { + if (isSocks && !options.socksProxyPort) { // https://www.chromium.org/developers/design-documents/network-stack/socks-proxy chromeArguments.push(`--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE ${proxyURL.hostname}"`); } chromeArguments.push(`--proxy-server=${proxy.server}`); const proxyBypassRules = []; // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 - if (this.attribution.playwright.options.socksProxyPort) + if (options.socksProxyPort) proxyBypassRules.push('<-loopback>'); if (proxy.bypass) proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index e165de972952e..fc2fcccdf7407 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -27,6 +27,7 @@ import type { ProxySettings } from './types'; import type { RecentLogsCollector } from './utils/debugLogger'; import type * as channels from '@protocol/channels'; import type { ChildProcess } from 'child_process'; +import type { Language } from '../utils'; export interface BrowserProcess { @@ -52,6 +53,7 @@ export type BrowserOptions = { browserLogsCollector: RecentLogsCollector, slowMo?: number; wsEndpoint?: string; // Only there when connected over web socket. + sdkLanguage?: Language; originalLaunchOptions: types.LaunchOptions; }; @@ -84,6 +86,10 @@ export abstract class Browser extends SdkObject { abstract version(): string; abstract userAgent(): string; + sdkLanguage() { + return this.options.sdkLanguage || this.attribution.playwright.options.sdkLanguage; + } + async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { validateBrowserContextOptions(options, this.options); let clientCertificatesProxy: ClientCertificatesProxy | undefined; diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 84f9cdaf12c41..5882687e90f1c 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -79,7 +79,7 @@ export abstract class BrowserType extends SdkObject { return browser; } - async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { timeout: number, cdpPort?: number, internalIgnoreHTTPSErrors?: boolean }): Promise { + async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { timeout: number, cdpPort?: number, internalIgnoreHTTPSErrors?: boolean, socksProxyPort?: number }): Promise { const launchOptions = this._validateLaunchOptions(options); const controller = new ProgressController(metadata, this); const browser = await controller.run(async progress => { @@ -288,8 +288,8 @@ export abstract class BrowserType extends SdkObject { headless = false; if (downloadsPath && !path.isAbsolute(downloadsPath)) downloadsPath = path.join(process.cwd(), downloadsPath); - if (this.attribution.playwright.options.socksProxyPort) - proxy = { server: `socks5://127.0.0.1:${this.attribution.playwright.options.socksProxyPort}` }; + if (options.socksProxyPort) + proxy = { server: `socks5://127.0.0.1:${options.socksProxyPort}` }; return { ...options, devtools, headless, downloadsPath, proxy }; } diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index 5f7408c80c62f..075e56a4690e9 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -331,14 +331,14 @@ export class Chromium extends BrowserType { const proxyURL = new URL(proxy.server); const isSocks = proxyURL.protocol === 'socks5:'; // https://www.chromium.org/developers/design-documents/network-settings - if (isSocks && !this.attribution.playwright.options.socksProxyPort) { + if (isSocks && !options.socksProxyPort) { // https://www.chromium.org/developers/design-documents/network-stack/socks-proxy chromeArguments.push(`--host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE ${proxyURL.hostname}"`); } chromeArguments.push(`--proxy-server=${proxy.server}`); const proxyBypassRules = []; // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 - if (this.attribution.playwright.options.socksProxyPort) + if (options.socksProxyPort) proxyBypassRules.push('<-loopback>'); if (proxy.bypass) proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 0edd05aa47d9e..17e6182714b7e 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -894,7 +894,7 @@ class FrameSession { async _createVideoRecorder(screencastId: string, options: types.PageScreencastOptions): Promise { assert(!this._screencastId); - const ffmpegPath = registry.findExecutable('ffmpeg')!.executablePathOrDie(this._page.attribution.playwright.options.sdkLanguage); + const ffmpegPath = registry.findExecutable('ffmpeg')!.executablePathOrDie(this._page.browserContext._browser.sdkLanguage()); this._videoRecorder = await VideoRecorder.launch(this._crPage._page, ffmpegPath, options); this._screencastId = screencastId; } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index d6fb1c8ee1e0c..4697f19aa6709 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -87,7 +87,7 @@ export class FrameExecutionContext extends js.ExecutionContext { const selectorsRegistry = this.frame._page.browserContext.selectors(); for (const [name, { source }] of selectorsRegistry._engines) customEngines.push({ name, source: `(${source})` }); - const sdkLanguage = this.frame.attribution.playwright.options.sdkLanguage; + const sdkLanguage = this.frame._page.browserContext._browser.sdkLanguage(); const options: InjectedScriptOptions = { isUnderTest: isUnderTest(), sdkLanguage, diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index 2c72f147ee25a..65c1534fdb8d2 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -134,7 +134,7 @@ export class FrameSelectors { for (const chunk of frameChunks) { visitAllSelectorParts(chunk, (part, nested) => { if (nested && part.name === 'internal:control' && part.body === 'enter-frame') { - const locator = asLocator(this.frame._page.attribution.playwright.options.sdkLanguage, selector); + const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); throw new InvalidSelectorError(`Frame locators are not allowed inside composite locators, while querying "${locator}"`); } }); diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 2ed8ca96b1b33..8623e4b1303ad 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1711,7 +1711,7 @@ export class Frame extends SdkObject { } private _asLocator(selector: string) { - return asLocator(this._page.attribution.playwright.options.sdkLanguage, selector); + return asLocator(this._page.browserContext._browser.sdkLanguage(), selector); } } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index b2a24a620fe01..13da603752150 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -484,11 +484,11 @@ export class Page extends SdkObject { } if (handler.resolved) { ++this._locatorHandlerRunningCounter; - progress.log(` found ${asLocator(this.attribution.playwright.options.sdkLanguage, handler.selector)}, intercepting action to run the handler`); + progress.log(` found ${asLocator(this.browserContext._browser.sdkLanguage(), handler.selector)}, intercepting action to run the handler`); const promise = handler.resolved.then(async () => { progress.throwIfAborted(); if (!handler.noWaitAfter) { - progress.log(` locator handler has finished, waiting for ${asLocator(this.attribution.playwright.options.sdkLanguage, handler.selector)} to be hidden`); + progress.log(` locator handler has finished, waiting for ${asLocator(this.browserContext._browser.sdkLanguage(), handler.selector)} to be hidden`); await this.mainFrame().waitForSelectorInternal(progress, handler.selector, false, { state: 'hidden' }); } else { progress.log(` locator handler has finished`); diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index 26273a258dddf..5879a6e668341 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -33,7 +33,6 @@ import type { CallMetadata } from './instrumentation'; import type { Page } from './page'; type PlaywrightOptions = { - socksProxyPort?: number; sdkLanguage: Language; isInternalPlaywright?: boolean; isServer?: boolean; diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index df4bf657a93d9..ed2aa6202b92e 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -63,7 +63,7 @@ export class ContextRecorder extends EventEmitter { this._params = params; this._delegate = delegate; this._recorderSources = []; - const language = params.language || context.attribution.playwright.options.sdkLanguage; + const language = params.language || context._browser.sdkLanguage(); this.setOutput(language, params.outputFile); // Make a copy of options to modify them later. diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index ca4eb042d8d49..5f69e225f2cfc 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -103,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { } private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise { - const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage; + const sdkLanguage = inspectedContext._browser.sdkLanguage(); const headed = !!inspectedContext._browser.options.headful; const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); const { context, page } = await launchApp(recorderPlaywright.chromium, { diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index cadd052b10caa..36d2048839632 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -109,7 +109,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps platform: process.platform, wallTime: 0, monotonicTime: 0, - sdkLanguage: context.attribution.playwright.options.sdkLanguage, + sdkLanguage: this._sdkLanguage(), testIdAttributeName, contextId: context.guid, }; @@ -122,6 +122,10 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps } } + private _sdkLanguage() { + return this._context instanceof BrowserContext ? this._context._browser.sdkLanguage() : this._context.attribution.playwright.options.sdkLanguage; + } + async resetForReuse() { // Discard previous chunk if any and ignore any errors there. await this.stopChunk({ mode: 'discard' }).catch(() => {}); @@ -136,7 +140,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps throw new Error('Tracing has been already started'); // Re-write for testing. - this._contextCreatedEvent.sdkLanguage = this._context.attribution.playwright.options.sdkLanguage; + this._contextCreatedEvent.sdkLanguage = this._sdkLanguage(); // TODO: passing the same name for two contexts makes them write into a single file // and conflict. diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 7904125c61171..68a5fdd5c76b7 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -169,7 +169,6 @@ export async function openTraceViewerApp(url: string, browserName: string, optio const traceViewerBrowser = isUnderTest() ? 'chromium' : browserName; const { context, page } = await launchApp(traceViewerPlaywright[traceViewerBrowser as 'chromium'], { - // TODO: store language in the trace. sdkLanguage: traceViewerPlaywright.options.sdkLanguage, windowSize: { width: 1280, height: 800 }, persistentContextOptions: { diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index 3b97aabbf81fe..f0c6ec3655aed 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -158,6 +158,7 @@ export type LaunchOptions = channels.BrowserTypeLaunchParams & { cdpPort?: number, proxyOverride?: ProxySettings, assistantMode?: boolean, + socksProxyPort?: number, }; export type BrowserContextOptions = channels.BrowserNewContextOptions & { From 2576ce2917ab8b983e677f686ed74c8fa63c4747 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 18 Jun 2025 10:41:35 +0100 Subject: [PATCH 37/71] Revert "chore: reduce scrolling during clicks (#36175)" (#36346) --- packages/injected/src/injectedScript.ts | 23 ++++------- packages/playwright-core/src/server/dom.ts | 44 +++++++++----------- tests/page/page-click.spec.ts | 48 ++++++++++++++++++++++ 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index b18251f836bb5..bcc6be892e4e9 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -53,9 +53,8 @@ export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'edit export type ElementStateWithoutStable = Exclude; export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' }; -export type HitTargetError = { hitTargetDescription: string, hasPositionStickyOrFixed: boolean }; export type HitTargetInterceptionResult = { - stop: () => 'done' | HitTargetError; + stop: () => 'done' | { hitTargetDescription: string }; }; interface WebKitLegacyDeviceOrientationEvent extends DeviceOrientationEvent { @@ -923,7 +922,7 @@ export class InjectedScript { input.dispatchEvent(new Event('change', { bubbles: true })); } - expectHitTarget(hitPoint: { x: number, y: number }, targetElement: Element): 'done' | HitTargetError { + expectHitTarget(hitPoint: { x: number, y: number }, targetElement: Element) { const roots: (Document | ShadowRoot)[] = []; // Get all component roots leading to the target element. @@ -976,21 +975,14 @@ export class InjectedScript { // Check whether hit target is the target or its descendant. const hitParents: Element[] = []; - const isHitParentPositionStickyOrFixed: boolean[] = []; while (hitElement && hitElement !== targetElement) { hitParents.push(hitElement); - isHitParentPositionStickyOrFixed.push(['sticky', 'fixed'].includes(this.window.getComputedStyle(hitElement).position)); hitElement = parentElementOrShadowHost(hitElement); } if (hitElement === targetElement) return 'done'; - // The description of the element that was hit instead of the target element. const hitTargetDescription = this.previewNode(hitParents[0] || this.document.documentElement); - // Whether any ancestor of the hit target has position: static. In this case, it could be - // beneficial to scroll the target element into different positions to reveal it. - let hasPositionStickyOrFixed = isHitParentPositionStickyOrFixed.some(x => x); - // Root is the topmost element in the hitTarget's chain that is not in the // element's chain. For example, it might be a dialog element that overlays // the target. @@ -1001,14 +993,13 @@ export class InjectedScript { if (index !== -1) { if (index > 1) rootHitTargetDescription = this.previewNode(hitParents[index - 1]); - hasPositionStickyOrFixed = isHitParentPositionStickyOrFixed.slice(0, index).some(x => x); break; } element = parentElementOrShadowHost(element); } if (rootHitTargetDescription) - return { hitTargetDescription: `${hitTargetDescription} from ${rootHitTargetDescription} subtree`, hasPositionStickyOrFixed }; - return { hitTargetDescription, hasPositionStickyOrFixed }; + return { hitTargetDescription: `${hitTargetDescription} from ${rootHitTargetDescription} subtree` }; + return { hitTargetDescription }; } // Life of a pointer action, for example click. @@ -1041,7 +1032,7 @@ export class InjectedScript { // 2k. (injected) Event interceptor is removed. // 2l. All navigations triggered between 2g-2k are awaited to be either committed or canceled. // 2m. If failed, wait for increasing amount of time before the next retry. - setupHitTargetInterceptor(node: Node, action: 'hover' | 'tap' | 'mouse' | 'drag', hitPoint: { x: number, y: number } | undefined, blockAllEvents: boolean): HitTargetInterceptionResult | 'error:notconnected' | string /* JSON.stringify(hitTargetDescription) */ { + setupHitTargetInterceptor(node: Node, action: 'hover' | 'tap' | 'mouse' | 'drag', hitPoint: { x: number, y: number } | undefined, blockAllEvents: boolean): HitTargetInterceptionResult | 'error:notconnected' | string /* hitTargetDescription */ { const element = this.retarget(node, 'button-link'); if (!element || !element.isConnected) return 'error:notconnected'; @@ -1051,7 +1042,7 @@ export class InjectedScript { // intercepting the action. const preliminaryResult = this.expectHitTarget(hitPoint, element); if (preliminaryResult !== 'done') - return JSON.stringify(preliminaryResult); + return preliminaryResult.hitTargetDescription; } // When dropping, the "element that is being dragged" often stays under the cursor, @@ -1066,7 +1057,7 @@ export class InjectedScript { 'tap': this._tapHitTargetInterceptorEvents, 'mouse': this._mouseHitTargetInterceptorEvents, }[action]; - let result: 'done' | HitTargetError | undefined; + let result: 'done' | { hitTargetDescription: string } | undefined; const listener = (event: PointerEvent | MouseEvent | TouchEvent) => { // Ignore events that we do not expect to intercept. diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 4697f19aa6709..989c31fa38cb2 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -24,7 +24,7 @@ import { isSessionClosedError } from './protocolError'; import * as rawInjectedScriptSource from '../generated/injectedScriptSource'; import type * as frames from './frames'; -import type { ElementState, HitTargetError, HitTargetInterceptionResult, InjectedScript, InjectedScriptOptions } from '@injected/injectedScript'; +import type { ElementState, HitTargetInterceptionResult, InjectedScript, InjectedScriptOptions } from '@injected/injectedScript'; import type { CallMetadata } from './instrumentation'; import type { Page } from './page'; import type { Progress } from './progress'; @@ -39,7 +39,7 @@ export type InputFilesItems = { }; type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down'; -type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | { missingState: ElementState } | HitTargetError | 'done'; +type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | { missingState: ElementState } | { hitTargetDescription: string } | 'done'; export class NonRecoverableDOMError extends Error { } @@ -331,7 +331,7 @@ export class ElementHandle extends js.JSHandle { }; } - async _retryAction(progress: Progress, actionName: string, action: () => Promise, options: { trial?: boolean, force?: boolean, skipActionPreChecks?: boolean }): Promise<'error:notconnected' | 'done'> { + async _retryAction(progress: Progress, actionName: string, action: (retry: number) => Promise, options: { trial?: boolean, force?: boolean, skipActionPreChecks?: boolean }): Promise<'error:notconnected' | 'done'> { let retry = 0; // We progressively wait longer between retries, up to 500ms. const waitTime = [0, 20, 100, 100, 500]; @@ -352,7 +352,7 @@ export class ElementHandle extends js.JSHandle { } if (!options.skipActionPreChecks && !options.force) await this._frame._page.performActionPreChecks(progress); - const result = await action(); + const result = await action(retry); ++retry; if (result === 'error:notvisible') { if (options.force) @@ -386,25 +386,19 @@ export class ElementHandle extends js.JSHandle { options: Omit<{ waitAfter: boolean | 'disabled' } & types.PointerActionOptions & types.PointerActionWaitOptions, 'timeout'>): Promise<'error:notconnected' | 'done'> { // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. const skipActionPreChecks = actionName === 'move and up'; - // By default, we scroll with protocol method to reveal the action point. - // However, that might not work to scroll from under position:sticky and position:fixed elements - // that overlay the target element. To fight this, we cycle through different - // scroll alignments. This works in most scenarios. - const scrollOptions: (ScrollIntoViewOptions | undefined)[] = [ - undefined, - { block: 'end', inline: 'end' }, - { block: 'center', inline: 'center' }, - { block: 'start', inline: 'start' }, - ]; - let scrollOptionIndex = 0; - return await this._retryAction(progress, actionName, async () => { - const forceScrollOptions = scrollOptions[scrollOptionIndex % scrollOptions.length]; - const result = await this._performPointerAction(progress, actionName, waitForEnabled, action, forceScrollOptions, options); - if (typeof result === 'object' && 'hasPositionStickyOrFixed' in result && result.hasPositionStickyOrFixed) - ++scrollOptionIndex; - else - scrollOptionIndex = 0; - return result; + return await this._retryAction(progress, actionName, async retry => { + // By default, we scroll with protocol method to reveal the action point. + // However, that might not work to scroll from under position:sticky elements + // that overlay the target element. To fight this, we cycle through different + // scroll alignments. This works in most scenarios. + const scrollOptions: (ScrollIntoViewOptions | undefined)[] = [ + undefined, + { block: 'end', inline: 'end' }, + { block: 'center', inline: 'center' }, + { block: 'start', inline: 'start' }, + ]; + const forceScrollOptions = scrollOptions[retry % scrollOptions.length]; + return await this._performPointerAction(progress, actionName, waitForEnabled, action, forceScrollOptions, options); }, { ...options, skipActionPreChecks }); } @@ -487,7 +481,7 @@ export class ElementHandle extends js.JSHandle { const error = handle.rawValue() as string; if (error === 'error:notconnected') return error; - return JSON.parse(error) as HitTargetError; // It is safe to parse, because we evaluated in utility. + return { hitTargetDescription: error }; } hitTargetInterceptionHandle = handle as any; progress.cleanupWhenAborted(() => { @@ -911,7 +905,7 @@ export class ElementHandle extends js.JSHandle { return this; } - async _checkFrameIsHitTarget(point: types.Point): Promise<{ framePoint: types.Point | undefined } | 'error:notconnected' | HitTargetError> { + async _checkFrameIsHitTarget(point: types.Point): Promise<{ framePoint: types.Point | undefined } | 'error:notconnected' | { hitTargetDescription: string }> { let frame = this._frame; const data: { frame: frames.Frame, frameElement: ElementHandle | null, pointInFrame: types.Point }[] = []; while (frame.parentFrame()) { diff --git a/tests/page/page-click.spec.ts b/tests/page/page-click.spec.ts index 9a023ade59017..b206480740c8b 100644 --- a/tests/page/page-click.spec.ts +++ b/tests/page/page-click.spec.ts @@ -419,6 +419,54 @@ it('should click the button behind sticky header', async ({ page }) => { expect(await page.evaluate(() => window['__clicked'])).toBe(true); }); +it('should click the button behind position:absolute header', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36339' }, +}, async ({ page }) => { + await page.setViewportSize({ width: 500, height: 240 }); + await page.setContent(` + + +
    +
  1. hi1
  2. hi2
  3. hi3
  4. hi4
  5. hi5
  6. hi6
  7. hi7
  8. hi8
  9. +
  10. hi9
  11. +
  12. hi10
  13. hi11
  14. hi12
  15. hi13
  16. hi14
  17. +
+ +
Overlay
+ `); + await page.$eval('ol', e => { + const target = document.querySelector('#target') as HTMLElement; + e.scrollTo({ top: target.offsetTop, behavior: 'instant' }); + }); + await page.click('#target'); + expect(await page.evaluate(() => window['__clicked'])).toBe(true); +}); + it('should click the button with px border with offset', async ({ page, server, browserName }) => { await page.goto(server.PREFIX + '/input/button.html'); await page.$eval('button', button => button.style.borderWidth = '8px'); From 2973b0b0c0404aef8eac15f2cc181892ca1ee408 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 18 Jun 2025 12:04:33 +0200 Subject: [PATCH 38/71] test: prevent indexeddb race conditions (#36347) --- tests/library/browsercontext-storage-state.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index 9aed5f7466217..972453e1b2eb9 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -360,10 +360,19 @@ it('should roundtrip local storage in third-party context', async ({ page, conte it('should support IndexedDB', async ({ page, server, contextFactory }) => { await page.goto(server.PREFIX + '/to-do-notifications/index.html'); + + await expect(page.locator('#notifications')).toMatchAriaSnapshot(` + - list: + - listitem: Database initialised. + `); await page.getByLabel('Task title').fill('Pet the cat'); await page.getByLabel('Hours').fill('1'); await page.getByLabel('Mins').fill('1'); await page.getByText('Add Task').click(); + await expect(page.locator('#notifications')).toMatchAriaSnapshot(` + - list: + - listitem: "Transaction completed: database modification finished." + `); const storageState = await page.context().storageState({ indexedDB: true }); expect(storageState.origins).toEqual([ From f050c3fc61574d2fcca469d1871da7230cfc0aa5 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 18 Jun 2025 13:59:09 +0100 Subject: [PATCH 39/71] fix(trace): include method into "Fetch" action title (#36350) --- .../src/utils/isomorphic/protocolMetainfo.ts | 2 +- packages/protocol/src/protocol.yml | 2 +- packages/trace-viewer/src/ui/actionList.tsx | 5 ++++- tests/library/trace-viewer.spec.ts | 12 ++++++------ tests/library/tracing.spec.ts | 2 +- tests/playwright-test/playwright.trace.spec.ts | 14 +++++++------- tests/playwright-test/reporter-html.spec.ts | 10 +++++----- tests/playwright-test/test-step.spec.ts | 6 +++--- 8 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 53fc12499dfb0..673dcd2612286 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -17,7 +17,7 @@ // This file is generated by generate_channels.js, do not edit manually. export const methodMetainfo = new Map([ - ['APIRequestContext.fetch', { title: 'Fetch "{url}"', }], + ['APIRequestContext.fetch', { title: '{method} "{url}"', }], ['APIRequestContext.fetchResponseBody', { internal: true, }], ['APIRequestContext.fetchLog', { internal: true, }], ['APIRequestContext.storageState', { internal: true, }], diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 849bc91305b4a..c33c381bdfdcc 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -366,7 +366,7 @@ APIRequestContext: commands: fetch: - title: Fetch "{url}" + title: '{method} "{url}"' parameters: url: string encodedParams: string? diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 6e86410e24d33..ec8d90bd7eb9c 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -164,7 +164,10 @@ export function renderTitleForCall(action: ActionTraceEvent): { elements: React. title.push(chunk); const param = formatProtocolParam(action.params, quotedText); - elements.push({param}); + if (match.index === 0) + elements.push(param); + else + elements.push({param}); title.push(param); currentIndex = match.index + fullMatch.length; } diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 71dc0c70f6dc5..942f89422f92a 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -1056,16 +1056,16 @@ test('should open two trace files', async ({ context, page, request, server, sho const traceViewer = await showTraceViewer([contextTrace, apiTrace]); - await traceViewer.selectAction('FETCH', 0); - await traceViewer.selectAction('FETCH', 1); - await traceViewer.selectAction('FETCH', 2); + await traceViewer.selectAction('GET'); + await traceViewer.selectAction('HEAD'); + await traceViewer.selectAction('POST'); await expect(traceViewer.actionTitles).toHaveText([ - /Fetch "\/simple\.json"/, + /GET "\/simple\.json"/, /Navigate to "\/input\/button\.html"/, - /Fetch "\/simplezip\.json"/, + /HEAD "\/simplezip\.json"/, /Click.*locator\('button'\)/, /Click.*locator\('button'\)/, - /Fetch "\/one-style\.css"/, + /POST "\/one-style\.css"/, ]); await traceViewer.page.getByRole('tab', { name: 'Metadata' }).click(); diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index 1a88370e38338..e1aff51bc24ad 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -165,7 +165,7 @@ test('should include context API requests', async ({ context, page, server }, te await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } }); await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip')); - expect(actions).toContain('Fetch "/simple.json"'); + expect(actions).toContain('POST "/simple.json"'); const harEntry = events.find(e => e.type === 'resource-snapshot'); expect(harEntry).toBeTruthy(); expect(harEntry.snapshot.request.url).toBe(server.PREFIX + '/simple.json'); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 08d49c00e70bd..745a1dee31156 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -98,7 +98,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { ' Fixture "page"', ' Create page', 'Navigate to "about:blank"', - 'Fetch "/empty.html"', + 'GET "/empty.html"', 'After Hooks', ' Fixture "page"', ' Fixture "context"', @@ -108,7 +108,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { expect(trace2.actionTree).toEqual([ 'Before Hooks', 'Create request context', - 'Fetch "/empty.html"', + 'GET "/empty.html"', 'After Hooks', ]); const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); @@ -121,7 +121,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { ' Fixture "page"', ' Create page', 'Navigate to "about:blank"', - 'Fetch "/empty.html"', + 'GET "/empty.html"', 'Expect "toBe"', 'After Hooks', ' Fixture "page"', @@ -328,7 +328,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve ' afterAll hook', ' Fixture "request"', ' Create request context', - ' Fetch "/empty.html"', + ' GET "/empty.html"', ' Fixture "request"', 'Worker Cleanup', ' Fixture "browser"', @@ -488,7 +488,7 @@ test(`trace:retain-on-failure should create trace if request context is disposed }, { trace: 'retain-on-failure' }); const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); const trace = await parseTrace(tracePath); - expect(trace.titles).toContain('Fetch "/empty.html"'); + expect(trace.titles).toContain('GET "/empty.html"'); expect(result.failed).toBe(1); }); @@ -1137,7 +1137,7 @@ test('trace:retain-on-first-failure should create trace if request context is di }, { trace: 'retain-on-first-failure' }); const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); const trace = await parseTrace(tracePath); - expect(trace.titles).toContain('Fetch "/empty.html"'); + expect(trace.titles).toContain('GET "/empty.html"'); expect(result.failed).toBe(1); }); @@ -1243,7 +1243,7 @@ test('should not nest top level expect into unfinished api calls ', { ' Fixture "page"', ' Create page', 'Navigate to "/index"', - 'Fetch "/hang"', + 'GET "/hang"', 'Expect "toBeVisible"', 'After Hooks', ' Fixture "page"', diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index fc57ad7f7314b..83465d8d06bbf 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -637,7 +637,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { await page.click('text=Source'); await expect(page.locator('.source-line-running')).toContainText('page.evaluate'); - await page.click('.action-title >> text=FETCH'); + await page.click('.action-title >> text=GET'); await page.click('text=Source'); await expect(page.locator('.source-line-running')).toContainText('request.get'); }); @@ -668,10 +668,10 @@ for (const useIntermediateMergeReport of [true, false] as const) { await page.getByRole('link', { name: 'View Trace' }).click(); // Trace viewer should not hang here when displaying parallal requests. - await expect(page.getByTestId('actions-tree')).toContainText('Fetch'); - await page.getByText('Fetch').nth(2).click(); - await page.getByText('Fetch').nth(1).click(); - await page.getByText('Fetch').nth(0).click(); + await expect(page.getByTestId('actions-tree')).toContainText('GET'); + await page.getByText('GET').nth(2).click(); + await page.getByText('GET').nth(1).click(); + await page.getByText('GET').nth(0).click(); }); test('should warn user when viewing via file:// protocol', async ({ runInlineTest, page, showReport }, testInfo) => { diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index af3aa21219b41..2e2447dab5051 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -1222,9 +1222,9 @@ pw:api |Wait for navigation @ a.test.ts:5 pw:api |Navigate to "data:" @ a.test.ts:6 pw:api |Click locator('button') @ a.test.ts:8 pw:api |Click getByRole('button') @ a.test.ts:9 -pw:api |Fetch "/empty.html" @ a.test.ts:10 +pw:api |GET "/empty.html" @ a.test.ts:10 pw:api |↪ error: -pw:api |Fetch "/empty.html" @ a.test.ts:11 +pw:api |GET "/empty.html" @ a.test.ts:11 pw:api |↪ error: hook |After Hooks fixture | request @@ -1519,7 +1519,7 @@ fixture | page pw:api | Create page test.step |custom step @ a.test.ts:4 pw:api | Navigate to "/empty.html" @ a.test.ts:12 -pw:api | Fetch "/empty.html" @ a.test.ts:6 +pw:api | GET "/empty.html" @ a.test.ts:6 expect | toBe @ a.test.ts:8 hook |After Hooks fixture | page From 50cb8a1ffb50ae2f7e365a2bedbdfed387415e09 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 19 Jun 2025 11:28:29 +0200 Subject: [PATCH 40/71] chore: prevent launching more browsers in server mode (#36353) --- .../src/remote/playwrightConnection.ts | 12 +++++------- .../src/remote/playwrightServer.ts | 8 -------- .../src/server/dispatchers/androidDispatcher.ts | 8 +++++++- .../server/dispatchers/browserTypeDispatcher.ts | 13 ++++++++++++- .../src/server/dispatchers/electronDispatcher.ts | 6 +++++- .../server/dispatchers/playwrightDispatcher.ts | 16 +++++++++------- .../playwright-core/src/server/utils/wsServer.ts | 3 --- tests/library/browsertype-connect.spec.ts | 6 ++++++ 8 files changed, 44 insertions(+), 28 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 94fe34e7aa5b6..1b56d45698530 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -131,16 +131,13 @@ export class PlaywrightConnection { const browser = await playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); browser.options.sdkLanguage = options.sdkLanguage; - this._cleanups.push(async () => { - for (const browser of playwright.allBrowsers()) - await browser.close({ reason: 'Connection terminated' }); - }); + this._cleanups.push(() => browser.close({ reason: 'Connection terminated' })); browser.on(Browser.Events.Disconnected, () => { // Underlying browser did close for some reason - force disconnect the client. this.close({ code: 1001, reason: 'Browser closed' }); }); - return new PlaywrightDispatcher(scope, playwright, { socksProxy: ownedSocksProxy, preLaunchedBrowser: browser }); + return new PlaywrightDispatcher(scope, playwright, { socksProxy: ownedSocksProxy, preLaunchedBrowser: browser, denyLaunch: true, }); } private async _initPreLaunchedBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { @@ -161,6 +158,7 @@ export class PlaywrightConnection { socksProxy: this._preLaunched.socksProxy, preLaunchedBrowser: browser, sharedBrowser: this._options.sharedBrowser, + denyLaunch: true, }); // In pre-launched mode, keep only the pre-launched browser. for (const b of playwright.allBrowsers()) { @@ -179,7 +177,7 @@ export class PlaywrightConnection { // Underlying browser did close for some reason - force disconnect the client. this.close({ code: 1001, reason: 'Android device disconnected' }); }); - const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, { preLaunchedAndroidDevice: androidDevice }); + const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, { preLaunchedAndroidDevice: androidDevice, denyLaunch: true }); this._cleanups.push(() => playwrightDispatcher.cleanup()); return playwrightDispatcher; } @@ -241,7 +239,7 @@ export class PlaywrightConnection { } }); - const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, { preLaunchedBrowser: browser }); + const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, { preLaunchedBrowser: browser, denyLaunch: true }); return playwrightDispatcher; } diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index e823f8ce87935..44b204a962637 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -16,7 +16,6 @@ import { PlaywrightConnection } from './playwrightConnection'; import { createPlaywright } from '../server/playwright'; -import { debugLogger } from '../server/utils/debugLogger'; import { Semaphore } from '../utils/isomorphic/semaphore'; import { DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT } from '../utils/isomorphic/time'; import { WSServer } from '../server/utils/wsServer'; @@ -132,13 +131,6 @@ export class PlaywrightServer { }, id, () => semaphore.release()); }, - - onClose: async () => { - debugLogger.log('server', 'closing browsers'); - if (this._preLaunchedPlaywright) - await Promise.all(this._preLaunchedPlaywright.allBrowsers().map(browser => browser.close({ reason: 'Playwright Server stopped' }))); - debugLogger.log('server', 'closed browsers'); - } }); } diff --git a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts index cce5f325d1508..ea5d31461c961 100644 --- a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts @@ -27,8 +27,10 @@ import type * as channels from '@protocol/channels'; export class AndroidDispatcher extends Dispatcher implements channels.AndroidChannel { _type_Android = true; - constructor(scope: RootDispatcher, android: Android) { + _denyLaunch: boolean; + constructor(scope: RootDispatcher, android: Android, denyLaunch: boolean) { super(scope, android, 'Android', {}); + this._denyLaunch = denyLaunch; } async devices(params: channels.AndroidDevicesParams): Promise { @@ -159,6 +161,8 @@ export class AndroidDeviceDispatcher extends Dispatcher { + if (this.parentScope()._denyLaunch) + throw new Error(`Launching more browsers is not allowed.`); const context = await this._object.launchBrowser(params.pkg, params); return { context: BrowserContextDispatcher.from(this, context) }; } @@ -168,6 +172,8 @@ export class AndroidDeviceDispatcher extends Dispatcher { + if (this.parentScope()._denyLaunch) + throw new Error(`Launching more browsers is not allowed.`); return { context: BrowserContextDispatcher.from(this, await this._object.connectToWebView(params.socketName)) }; } } diff --git a/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts index e97b7cdc757d0..3b71e04072ac7 100644 --- a/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserTypeDispatcher.ts @@ -25,19 +25,27 @@ import type * as channels from '@protocol/channels'; export class BrowserTypeDispatcher extends Dispatcher implements channels.BrowserTypeChannel { _type_BrowserType = true; - constructor(scope: RootDispatcher, browserType: BrowserType) { + private readonly _denyLaunch: boolean; + constructor(scope: RootDispatcher, browserType: BrowserType, denyLaunch: boolean) { super(scope, browserType, 'BrowserType', { executablePath: browserType.executablePath(), name: browserType.name() }); + this._denyLaunch = denyLaunch; } async launch(params: channels.BrowserTypeLaunchParams, metadata: CallMetadata): Promise { + if (this._denyLaunch) + throw new Error(`Launching more browsers is not allowed.`); + const browser = await this._object.launch(metadata, params); return { browser: new BrowserDispatcher(this, browser) }; } async launchPersistentContext(params: channels.BrowserTypeLaunchPersistentContextParams, metadata: CallMetadata): Promise { + if (this._denyLaunch) + throw new Error(`Launching more browsers is not allowed.`); + const browserContext = await this._object.launchPersistentContext(metadata, params.userDataDir, params); const browserDispatcher = new BrowserDispatcher(this, browserContext._browser); const contextDispatcher = BrowserContextDispatcher.from(browserDispatcher, browserContext); @@ -45,6 +53,9 @@ export class BrowserTypeDispatcher extends Dispatcher { + if (this._denyLaunch) + throw new Error(`Launching more browsers is not allowed.`); + const browser = await this._object.connectOverCDP(metadata, params.endpointURL, params); const browserDispatcher = new BrowserDispatcher(this, browser); return { diff --git a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts index 4eab4fcfc2438..7660526176980 100644 --- a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts @@ -28,12 +28,16 @@ import type * as channels from '@protocol/channels'; export class ElectronDispatcher extends Dispatcher implements channels.ElectronChannel { _type_Electron = true; + _denyLaunch: boolean; - constructor(scope: RootDispatcher, electron: Electron) { + constructor(scope: RootDispatcher, electron: Electron, denyLaunch: boolean) { super(scope, electron, 'Electron', {}); + this._denyLaunch = denyLaunch; } async launch(params: channels.ElectronLaunchParams): Promise { + if (this._denyLaunch) + throw new Error(`Launching more browsers is not allowed.`); const electronApplication = await this._object.launch(params); return { electronApplication: new ElectronApplicationDispatcher(this, electronApplication) }; } diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 63e2ba997fe35..74ad4b4ab06f8 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -37,6 +37,7 @@ import type * as channels from '@protocol/channels'; type PlaywrightDispatcherOptions = { socksProxy?: SocksProxy; + denyLaunch?: boolean; preLaunchedBrowser?: Browser; preLaunchedAndroidDevice?: AndroidDevice; sharedBrowser?: boolean; @@ -47,12 +48,13 @@ export class PlaywrightDispatcher extends Dispatcher void; onUpgrade: (request: http.IncomingMessage, socket: stream.Duplex) => { error: string } | undefined; onConnection: (request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) => WSConnection; - onClose(): Promise; }; export class WSServer { @@ -137,7 +136,5 @@ export class WSServer { this._wsServer = undefined; this.server = undefined; debugLogger.log('server', 'closed server'); - - await this._delegate.onClose?.(); } } diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index 1298e38b5e6a4..80653941e60b6 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -1039,6 +1039,12 @@ test.describe('launchServer only', () => { }); } }); + + test('cannot launch another browser', async ({ connect, startRemoteServer }) => { + const remoteServer = await startRemoteServer('launchServer'); + const browser = await connect(remoteServer.wsEndpoint()) as any; + await expect(browser._parent.launch({ timeout: 0 })).rejects.toThrowError('Launching more browsers is not allowed.'); + }); }); test('should refuse connecting when versions do not match', async ({ connect, childProcess }) => { From 66e903021290f6cde0f7be53b70fdd02b976ed05 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 19 Jun 2025 12:49:58 +0200 Subject: [PATCH 41/71] chore: lift up playwright prelaunch (#36330) --- .../src/remote/playwrightConnection.ts | 37 ++++++++----------- .../src/remote/playwrightServer.ts | 14 +++---- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 1b56d45698530..f96eded9149f7 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -15,7 +15,7 @@ */ import { SocksProxy } from '../server/utils/socksProxy'; -import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher, createPlaywright } from '../server'; +import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher } from '../server'; import { AndroidDevice } from '../server/android/android'; import { Browser } from '../server/browser'; import { DebugControllerDispatcher } from '../server/dispatchers/debugControllerDispatcher'; @@ -42,7 +42,6 @@ type Options = { }; type PreLaunched = { - playwright?: Playwright | undefined; browser?: Browser | undefined; androidDevice?: AndroidDevice | undefined; socksProxy?: SocksProxy | undefined; @@ -55,18 +54,18 @@ export class PlaywrightConnection { private _cleanups: (() => Promise)[] = []; private _id: string; private _disconnected = false; + private _playwright: Playwright; private _preLaunched: PreLaunched; private _options: Options; private _root: DispatcherScope; private _profileName: string; - constructor(lock: Promise, clientType: ClientType, ws: WebSocket, options: Options, preLaunched: PreLaunched, id: string, onClose: () => void) { + constructor(lock: Promise, clientType: ClientType, ws: WebSocket, options: Options, playwright: Playwright, preLaunched: PreLaunched, id: string, onClose: () => void) { this._ws = ws; + this._playwright = playwright; this._preLaunched = preLaunched; this._options = options; options.launchOptions = filterLaunchOptions(options.launchOptions, options.allowFSPaths); - if (clientType === 'reuse-browser' || clientType === 'pre-launched-browser-or-android') - assert(preLaunched.playwright); if (clientType === 'pre-launched-browser-or-android') assert(preLaunched.browser || preLaunched.androidDevice); this._onClose = onClose; @@ -118,8 +117,6 @@ export class PlaywrightConnection { private async _initLaunchBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { debugLogger.log('server', `[${this._id}] engaged launch mode for "${this._options.browserName}"`); - const playwright = createPlaywright({ sdkLanguage: options.sdkLanguage, isServer: true }); - const ownedSocksProxy = await this._createOwnedSocksProxy(); let browserName = this._options.browserName; if ('bidi' === browserName) { @@ -128,7 +125,7 @@ export class PlaywrightConnection { else browserName = 'bidiChromium'; } - const browser = await playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); + const browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); browser.options.sdkLanguage = options.sdkLanguage; this._cleanups.push(() => browser.close({ reason: 'Connection terminated' })); @@ -137,12 +134,11 @@ export class PlaywrightConnection { this.close({ code: 1001, reason: 'Browser closed' }); }); - return new PlaywrightDispatcher(scope, playwright, { socksProxy: ownedSocksProxy, preLaunchedBrowser: browser, denyLaunch: true, }); + return new PlaywrightDispatcher(scope, this._playwright, { socksProxy: ownedSocksProxy, preLaunchedBrowser: browser, denyLaunch: true, }); } private async _initPreLaunchedBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { debugLogger.log('server', `[${this._id}] engaged pre-launched (browser) mode`); - const playwright = this._preLaunched.playwright!; // Note: connected client owns the socks proxy and configures the pattern. this._preLaunched.socksProxy?.setPattern(this._options.socksProxyPattern); @@ -154,14 +150,14 @@ export class PlaywrightConnection { this.close({ code: 1001, reason: 'Browser closed' }); }); - const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, { + const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { socksProxy: this._preLaunched.socksProxy, preLaunchedBrowser: browser, sharedBrowser: this._options.sharedBrowser, denyLaunch: true, }); // In pre-launched mode, keep only the pre-launched browser. - for (const b of playwright.allBrowsers()) { + for (const b of this._playwright.allBrowsers()) { if (b !== browser) await b.close({ reason: 'Connection terminated' }); } @@ -171,22 +167,20 @@ export class PlaywrightConnection { private async _initPreLaunchedAndroidMode(scope: RootDispatcher) { debugLogger.log('server', `[${this._id}] engaged pre-launched (Android) mode`); - const playwright = this._preLaunched.playwright!; const androidDevice = this._preLaunched.androidDevice!; androidDevice.on(AndroidDevice.Events.Close, () => { // Underlying browser did close for some reason - force disconnect the client. this.close({ code: 1001, reason: 'Android device disconnected' }); }); - const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, { preLaunchedAndroidDevice: androidDevice, denyLaunch: true }); + const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { preLaunchedAndroidDevice: androidDevice, denyLaunch: true }); this._cleanups.push(() => playwrightDispatcher.cleanup()); return playwrightDispatcher; } private _initDebugControllerMode(): DebugControllerDispatcher { debugLogger.log('server', `[${this._id}] engaged reuse controller mode`); - const playwright = this._preLaunched.playwright!; // Always create new instance based on the reused Playwright instance. - return new DebugControllerDispatcher(this._dispatcherConnection, playwright.debugController); + return new DebugControllerDispatcher(this._dispatcherConnection, this._playwright.debugController); } private async _initReuseBrowsersMode(scope: RootDispatcher, options: channels.RootInitializeParams) { @@ -194,10 +188,9 @@ export class PlaywrightConnection { // clients come and go, while the browser stays the same. debugLogger.log('server', `[${this._id}] engaged reuse browsers mode for ${this._options.browserName}`); - const playwright = this._preLaunched.playwright!; const requestedOptions = launchOptionsHash(this._options.launchOptions); - let browser = playwright.allBrowsers().find(b => { + let browser = this._playwright.allBrowsers().find(b => { if (b.options.name !== this._options.browserName) return false; const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); @@ -205,7 +198,7 @@ export class PlaywrightConnection { }); // Close remaining browsers of this type+channel. Keep different browser types for the speed. - for (const b of playwright.allBrowsers()) { + for (const b of this._playwright.allBrowsers()) { if (b === browser) continue; if (b.options.name === this._options.browserName && b.options.channel === this._options.launchOptions.channel) @@ -213,7 +206,7 @@ export class PlaywrightConnection { } if (!browser) { - browser = await playwright[(this._options.browserName || 'chromium') as 'chromium'].launch(serverSideCallMetadata(), { + browser = await this._playwright[(this._options.browserName || 'chromium') as 'chromium'].launch(serverSideCallMetadata(), { ...this._options.launchOptions, headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS, }); @@ -227,7 +220,7 @@ export class PlaywrightConnection { this._cleanups.push(async () => { // Don't close the pages so that user could debug them, // but close all the empty browsers and contexts to clean up. - for (const browser of playwright.allBrowsers()) { + for (const browser of this._playwright.allBrowsers()) { for (const context of browser.contexts()) { if (!context.pages().length) await context.close({ reason: 'Connection terminated' }); @@ -239,7 +232,7 @@ export class PlaywrightConnection { } }); - const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, { preLaunchedBrowser: browser, denyLaunch: true }); + const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { preLaunchedBrowser: browser, denyLaunch: true }); return playwrightDispatcher; } diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 44b204a962637..a6add53eb4e14 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -40,16 +40,17 @@ type ServerOptions = { }; export class PlaywrightServer { - private _preLaunchedPlaywright: Playwright | undefined; + private _playwright: Playwright; private _options: ServerOptions; private _wsServer: WSServer; constructor(options: ServerOptions) { this._options = options; if (options.preLaunchedBrowser) - this._preLaunchedPlaywright = options.preLaunchedBrowser.attribution.playwright; + this._playwright = options.preLaunchedBrowser.attribution.playwright; if (options.preLaunchedAndroidDevice) - this._preLaunchedPlaywright = options.preLaunchedAndroidDevice._android.attribution.playwright; + this._playwright = options.preLaunchedAndroidDevice._android.attribution.playwright; + this._playwright ??= createPlaywright({ sdkLanguage: 'javascript', isServer: true }); const browserSemaphore = new Semaphore(this._options.maxConnections); const controllerSemaphore = new Semaphore(1); @@ -95,11 +96,6 @@ export class PlaywrightServer { // Instantiate playwright for the extension modes. const isExtension = this._options.mode === 'extension'; - if (isExtension) { - if (!this._preLaunchedPlaywright) - this._preLaunchedPlaywright = createPlaywright({ sdkLanguage: 'javascript', isServer: true }); - } - let clientType: ClientType = 'launch-browser'; let semaphore: Semaphore = browserSemaphore; if (isExtension && url.searchParams.has('debug-controller')) { @@ -123,8 +119,8 @@ export class PlaywrightServer { allowFSPaths: this._options.mode === 'extension', sharedBrowser: this._options.mode === 'launchServerShared', }, + this._playwright, { - playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser, androidDevice: this._options.preLaunchedAndroidDevice, socksProxy: this._options.preLaunchedSocksProxy, From 5f65f32d26f99a8c7243e7ec0bf75f835d789030 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 19 Jun 2025 11:06:48 -0700 Subject: [PATCH 42/71] test: update expectation for secure cookie test on WK Win (#36361) --- tests/library/browsercontext-proxy.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/library/browsercontext-proxy.spec.ts b/tests/library/browsercontext-proxy.spec.ts index fcdae718239c0..7a68a3c318efa 100644 --- a/tests/library/browsercontext-proxy.spec.ts +++ b/tests/library/browsercontext-proxy.spec.ts @@ -64,7 +64,7 @@ it('should use proxy', async ({ contextFactory, server, proxyServer }) => { await context.close(); }); -it('should send secure cookies to subdomain.localhost', async ({ contextFactory, browserName, server, proxyServer }) => { +it('should send secure cookies to subdomain.localhost', async ({ contextFactory, browserName, server, isWindows, proxyServer }) => { proxyServer.forwardTo(server.PORT); const context = await contextFactory({ proxy: { server: `localhost:${proxyServer.PORT}` }, @@ -88,7 +88,7 @@ it('should send secure cookies to subdomain.localhost', async ({ contextFactory, name: 'non-secure', domain: 'subdomain.localhost', }, - ...(browserName === 'webkit' ? [] : [{ + ...((browserName === 'webkit') && !isWindows ? [] : [{ name: 'secure', domain: 'subdomain.localhost', }]), From 07d18242c93fb9e6a3f523dfc6474272afe0dec5 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 08:59:05 +0200 Subject: [PATCH 43/71] docs: correct spelling of 'informational' in README badge link (#36367) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b5dcab4d78cfc..d912d0d166770 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-138.0.7204.23-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-139.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-138.0.7204.23-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-139.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.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) From ab7b18e36c03ac92bec08733a8dd9ce528aa4c32 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 09:23:56 +0200 Subject: [PATCH 44/71] fix(ct): fsWatcher update comparison (#36366) --- packages/playwright/src/fsWatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright/src/fsWatcher.ts b/packages/playwright/src/fsWatcher.ts index 4078e4f9945c7..a6989defcfcd7 100644 --- a/packages/playwright/src/fsWatcher.ts +++ b/packages/playwright/src/fsWatcher.ts @@ -33,7 +33,7 @@ export class Watcher { } async update(watchedPaths: string[], ignoredFolders: string[], reportPending: boolean) { - if (JSON.stringify([this._watchedPaths, this._ignoredFolders]) === JSON.stringify(watchedPaths, ignoredFolders)) + if (JSON.stringify([this._watchedPaths, this._ignoredFolders]) === JSON.stringify([watchedPaths, ignoredFolders])) return; if (reportPending) From d4c0d752371136c422434b28200cf0cbb68c7fc5 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 09:01:46 +0100 Subject: [PATCH 45/71] chore: make fetch progress "strict" (#36318) --- packages/playwright-core/src/server/fetch.ts | 49 +++++++------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 6d0d2281fc3f5..26339540fa015 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -27,7 +27,7 @@ import { BrowserContext, verifyClientCertificates } from './browserContext'; import { Cookie, CookieStore, domainMatches, parseRawCookie } from './cookieStore'; import { MultipartFormData } from './formData'; import { SdkObject } from './instrumentation'; -import { ProgressController } from './progress'; +import { isAbortError, ProgressController } from './progress'; import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent, timingForSocket } from './utils/happyEyeballs'; import { Tracing } from './trace/recorder/tracing'; @@ -84,11 +84,12 @@ export type APIRequestFinishedEvent = { type SendRequestOptions = https.RequestOptions & { maxRedirects: number, - deadline: number, headers: HeadersObject, __testHookLookup?: (hostname: string) => LookupAddress[] }; +type SendRequestResult = Omit & { body: Buffer }; + export abstract class APIRequestContext extends SdkObject { static Events = { Dispose: 'dispose', @@ -185,16 +186,11 @@ export abstract class APIRequestContext extends SdkObject { let maxRedirects = params.maxRedirects ?? (defaults.maxRedirects ?? 20); maxRedirects = maxRedirects === 0 ? -1 : maxRedirects; - const timeout = params.timeout; - const deadline = timeout && (monotonicTime() + timeout); - const options: SendRequestOptions = { method, headers, agent, maxRedirects, - timeout, - deadline, ...getMatchingTLSOptionsForOrigin(this._defaultOptions().clientCertificates, requestUrl.origin), __testHookLookup: (params as any).__testHookLookup, }; @@ -205,10 +201,10 @@ export abstract class APIRequestContext extends SdkObject { const postData = serializePostData(params, headers); if (postData) setHeader(headers, 'content-length', String(postData.byteLength)); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); const fetchResponse = await controller.run(progress => { return this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries); - }, timeout); + }, params.timeout); const fetchUid = this._storeResponseBody(fetchResponse.body); this.fetchLog.set(fetchUid, controller.metadata.log); const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.failOnStatusCode; @@ -252,10 +248,10 @@ export abstract class APIRequestContext extends SdkObject { return cookies; } - private async _updateRequestCookieHeader(url: URL, headers: HeadersObject) { + private async _updateRequestCookieHeader(progress: Progress, url: URL, headers: HeadersObject) { if (getHeader(headers, 'cookie') !== undefined) return; - const contextCookies = await this._cookies(url); + const contextCookies = await progress.race(this._cookies(url)); // Browser context returns cookies with domain matching both .example.com and // example.com. Those without leading dot are only sent when domain is strictly // matching example.com, but not for sub.example.com. @@ -266,31 +262,33 @@ export abstract class APIRequestContext extends SdkObject { } } - private async _sendRequestWithRetries(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer, maxRetries?: number): Promise & { body: Buffer }>{ + private async _sendRequestWithRetries(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer, maxRetries?: number): Promise{ maxRetries ??= 0; let backoff = 250; for (let i = 0; i <= maxRetries; i++) { try { return await this._sendRequest(progress, url, options, postData); } catch (e) { + if (isAbortError(e)) + throw e; e = rewriteOpenSSLErrorIfNeeded(e); if (maxRetries === 0) throw e; - if (i === maxRetries || (options.deadline && monotonicTime() + backoff > options.deadline)) + if (i === maxRetries) throw new Error(`Failed after ${i + 1} attempt(s): ${e}`); // Retry on connection reset only. if (e.code !== 'ECONNRESET') throw e; progress.log(` Received ECONNRESET, will retry after ${backoff}ms.`); - await new Promise(f => setTimeout(f, backoff)); + await progress.wait(backoff); backoff *= 2; } } throw new Error('Unreachable'); } - private async _sendRequest(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise & { body: Buffer }>{ - await this._updateRequestCookieHeader(url, options.headers); + private async _sendRequest(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise{ + await this._updateRequestCookieHeader(progress, url, options.headers); const requestCookies = getHeader(options.headers, 'cookie')?.split(';').map(p => { const [name, value] = p.split('=').map(v => v.trim()); @@ -305,7 +303,7 @@ export abstract class APIRequestContext extends SdkObject { }; this.emit(APIRequestContext.Events.Request, requestEvent); - return new Promise((fulfill, reject) => { + const resultPromise = new Promise((fulfill, reject) => { const requestConstructor: ((url: URL, options: http.RequestOptions, callback?: (res: http.IncomingMessage) => void) => http.ClientRequest) = (url.protocol === 'https:' ? https : http).request; // If we have a proxy agent already, do not override it. @@ -402,8 +400,6 @@ export abstract class APIRequestContext extends SdkObject { headers, agent: options.agent, maxRedirects: options.maxRedirects - 1, - timeout: options.timeout, - deadline: options.deadline, ...getMatchingTLSOptionsForOrigin(this._defaultOptions().clientCertificates, url.origin), __testHookLookup: options.__testHookLookup, }; @@ -492,6 +488,7 @@ export abstract class APIRequestContext extends SdkObject { body.on('end', notifyBodyFinished); }); request.on('error', reject); + progress.cleanupWhenAborted(() => request.destroy()); listeners.push( eventsHelper.addEventListener(this, APIRequestContext.Events.Dispose, () => { @@ -543,23 +540,11 @@ export abstract class APIRequestContext extends SdkObject { progress.log(` ${name}: ${value}`); } - if (options.deadline) { - const rejectOnTimeout = () => { - reject(new Error(`Request timed out after ${options.timeout}ms`)); - request.destroy(); - }; - const remaining = options.deadline - monotonicTime(); - if (remaining <= 0) { - rejectOnTimeout(); - return; - } - request.setTimeout(remaining, rejectOnTimeout); - } - if (postData) request.write(postData); request.end(); }); + return progress.race(resultPromise); } private _getHttpCredentials(url: URL) { From 55cb7c916c820c85813ce7eab40914a35586c714 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 09:02:00 +0100 Subject: [PATCH 46/71] chore: make navigation actions' progress "strict" (#36321) --- packages/playwright-core/src/server/frames.ts | 41 ++++++++++--------- packages/playwright-core/src/server/helper.ts | 2 +- .../playwright-core/src/server/network.ts | 2 +- packages/playwright-core/src/server/page.ts | 26 +++++++----- tests/page/page-set-content.spec.ts | 28 +++++++++++++ 5 files changed, 66 insertions(+), 33 deletions(-) diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 8623e4b1303ad..4d2d62df9ab23 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -583,7 +583,7 @@ export class Frame extends SdkObject { } } - async raceNavigationAction(progress: Progress, options: types.GotoOptions, action: () => Promise): Promise { + async raceNavigationAction(progress: Progress, action: () => Promise): Promise { return LongStandingScope.raceMultiple([ this._detachedScope, this._page.openScope, @@ -592,7 +592,7 @@ export class Frame extends SdkObject { const data = this._redirectedNavigations.get(e.documentId); if (data) { progress.log(`waiting for redirected navigation to "${data.url}"`); - return data.gotoPromise; + return progress.race(data.gotoPromise); } } throw e; @@ -600,7 +600,7 @@ export class Frame extends SdkObject { } redirectNavigation(url: string, documentId: string, referer: string | undefined) { - const controller = new ProgressController(serverSideCallMetadata(), this); + const controller = new ProgressController(serverSideCallMetadata(), this, 'strict'); const data = { url, gotoPromise: controller.run(progress => this.gotoImpl(progress, url, { referer }), 0), @@ -610,10 +610,10 @@ export class Frame extends SdkObject { } async goto(metadata: CallMetadata, url: string, options: types.GotoOptions): Promise { - const constructedNavigationURL = constructURLBasedOnBaseURL(this._page.browserContext._options.baseURL, url); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(progress => { - return this.raceNavigationAction(progress, options, async () => this.gotoImpl(progress, constructedNavigationURL, options)); + const constructedNavigationURL = constructURLBasedOnBaseURL(this._page.browserContext._options.baseURL, url); + return this.raceNavigationAction(progress, async () => this.gotoImpl(progress, constructedNavigationURL, options)); }, options.timeout); } @@ -633,7 +633,7 @@ export class Frame extends SdkObject { const navigationEvents: NavigationEvent[] = []; const collectNavigations = (arg: NavigationEvent) => navigationEvents.push(arg); this.on(Frame.Events.InternalNavigation, collectNavigations); - const navigateResult = await this._page.delegate.navigateFrame(this, url, referer).finally( + const navigateResult = await progress.race(this._page.delegate.navigateFrame(this, url, referer)).finally( () => this.off(Frame.Events.InternalNavigation, collectNavigations)); let event: NavigationEvent; @@ -669,7 +669,7 @@ export class Frame extends SdkObject { await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; const request = event.newDocument ? event.newDocument.request : undefined; - const response = request ? request._finalRequest().response() : null; + const response = request ? progress.race(request._finalRequest().response()) : null; return response; } @@ -693,7 +693,7 @@ export class Frame extends SdkObject { await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; const request = navigationEvent.newDocument ? navigationEvent.newDocument.request : undefined; - return request ? request._finalRequest().response() : null; + return request ? progress.race(request._finalRequest().response()) : null; } async _waitForLoadState(progress: Progress, state: types.LifecycleEvent): Promise { @@ -871,26 +871,27 @@ export class Frame extends SdkObject { } async setContent(metadata: CallMetadata, html: string, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this.raceNavigationAction(progress, options, async () => { + await this.raceNavigationAction(progress, async () => { const waitUntil = options.waitUntil === undefined ? 'load' : options.waitUntil; progress.log(`setting frame content, waiting until "${waitUntil}"`); const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`; - const context = await this._utilityContext(); - const lifecyclePromise = new Promise((resolve, reject) => { - this._page.frameManager._consoleMessageTags.set(tag, () => { - // Clear lifecycle right after document.open() - see 'tag' below. - this._onClearLifecycle(); - this._waitForLoadState(progress, waitUntil).then(resolve).catch(reject); - }); + const context = await progress.race(this._utilityContext()); + const tagPromise = new ManualPromise(); + this._page.frameManager._consoleMessageTags.set(tag, () => { + // Clear lifecycle right after document.open() - see 'tag' below. + this._onClearLifecycle(); + tagPromise.resolve(); }); - const contentPromise = context.evaluate(({ html, tag }) => { + progress.cleanupWhenAborted(() => this._page.frameManager._consoleMessageTags.delete(tag)); + const lifecyclePromise = progress.race(tagPromise).then(() => this._waitForLoadState(progress, waitUntil)); + const contentPromise = progress.race(context.evaluate(({ html, tag }) => { document.open(); console.debug(tag); // eslint-disable-line no-console document.write(html); document.close(); - }, { html, tag }); + }, { html, tag })); await Promise.all([contentPromise, lifecyclePromise]); return null; }); diff --git a/packages/playwright-core/src/server/helper.ts b/packages/playwright-core/src/server/helper.ts index e5bf38dd7777c..fb6622846ca99 100644 --- a/packages/playwright-core/src/server/helper.ts +++ b/packages/playwright-core/src/server/helper.ts @@ -72,7 +72,7 @@ class Helper { }); const dispose = () => eventsHelper.removeEventListeners(listeners); progress.cleanupWhenAborted(dispose); - return { promise, dispose }; + return { promise: progress.race(promise), dispose }; } static secondsToRoundishMillis(value: number): number { diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 739c109d8c48d..5536693c75ab7 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -190,7 +190,7 @@ export class Request extends SdkObject { return this._overrides?.headers || this._rawRequestHeadersPromise; } - response(): PromiseLike { + response(): Promise { return this._waitForResponsePromise; } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 13da603752150..7e56b07abccb5 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -369,22 +369,22 @@ export class Page extends SdkObject { } async reload(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this); - return controller.run(progress => this.mainFrame().raceNavigationAction(progress, options, async () => { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to reload(), // so we should await it immediately. const [response] = await Promise.all([ // Reload must be a new document, and should not be confused with a stray pushState. this.mainFrame()._waitForNavigation(progress, true /* requiresNewDocument */, options), - this.delegate.reload(), + progress.race(this.delegate.reload()), ]); return response; }), options.timeout); } async goBack(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this); - return controller.run(progress => this.mainFrame().raceNavigationAction(progress, options, async () => { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to goBack, // so we should catch it immediately. let error: Error | undefined; @@ -392,9 +392,11 @@ export class Page extends SdkObject { error = e; return null; }); - const result = await this.delegate.goBack(); - if (!result) + const result = await progress.race(this.delegate.goBack()); + if (!result) { + waitPromise.catch(() => {}); // Avoid an unhandled rejection. return null; + } const response = await waitPromise; if (error) throw error; @@ -403,8 +405,8 @@ export class Page extends SdkObject { } async goForward(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this); - return controller.run(progress => this.mainFrame().raceNavigationAction(progress, options, async () => { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to goForward, // so we should catch it immediately. let error: Error | undefined; @@ -412,9 +414,11 @@ export class Page extends SdkObject { error = e; return null; }); - const result = await this.delegate.goForward(); - if (!result) + const result = await progress.race(this.delegate.goForward()); + if (!result) { + waitPromise.catch(() => {}); // Avoid an unhandled rejection. return null; + } const response = await waitPromise; if (error) throw error; diff --git a/tests/page/page-set-content.spec.ts b/tests/page/page-set-content.spec.ts index 27fe18c860af3..38f5f111cc02a 100644 --- a/tests/page/page-set-content.spec.ts +++ b/tests/page/page-set-content.spec.ts @@ -129,3 +129,31 @@ it('should return empty content there is no iframe src', async ({ page, browserN expect(page.frames().length).toBe(2); expect(await page.frames()[1].content()).toBe(''); }); + +it('should handle timeout properly', async ({ page, toImpl, browserName }) => { + it.skip(browserName === 'firefox', 'tampering with console.debug in utility world does not work'); + + await toImpl(page).mainFrame().evaluateExpression(String(() => { + window['saved'] = console.debug.bind(console); + console.debug = () => {}; + }), { isFunction: true, world: 'utility' }); + const error = await page.setContent(`
hello
`, { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('page.setContent: Timeout 1000ms exceeded'); + + // Should recover after timeout. + await toImpl(page).mainFrame().evaluateExpression(String(() => { + console.debug = window['saved']; + }), { isFunction: true, world: 'utility' }); + await page.setContent(`
world
`); + await expect(page.locator('div')).toHaveText('world'); +}); + +it('should handle timeout properly 2', async ({ page, toImpl }) => { + await toImpl(page).mainFrame().evaluateExpression(String(() => { + document.close = () => { + while (true) {} + }; + }), { isFunction: true, world: 'utility' }); + const error = await page.setContent(`
hello
`, { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('page.setContent: Timeout 1000ms exceeded'); +}); From 20b8784c918555e8cc4241d1e3b2e47a92f7b99d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 09:02:14 +0100 Subject: [PATCH 47/71] chore: make screenshot progress "strict" (#36323) --- .../src/server/dispatchers/frameDispatcher.ts | 2 +- packages/playwright-core/src/server/dom.ts | 2 +- .../src/server/firefox/ffPage.ts | 1 - packages/playwright-core/src/server/frames.ts | 120 +++++++++--------- packages/playwright-core/src/server/page.ts | 7 +- .../src/server/screenshotter.ts | 51 +++----- 6 files changed, 85 insertions(+), 98 deletions(-) diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 3afab78c254f7..38c31056f2304 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -246,7 +246,7 @@ export class FrameDispatcher extends Dispatcher { - return { handle: ElementHandleDispatcher.fromJSOrElementHandle(this, await this._frame._waitForFunctionExpression(metadata, params.expression, params.isFunction, parseArgument(params.arg), params)) }; + return { handle: ElementHandleDispatcher.fromJSOrElementHandle(this, await this._frame.waitForFunctionExpression(metadata, params.expression, params.isFunction, parseArgument(params.arg), params)) }; } async title(params: channels.FrameTitleParams, metadata: CallMetadata): Promise { diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 989c31fa38cb2..ce9ce887c03b3 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -833,7 +833,7 @@ export class ElementHandle extends js.JSHandle { } async screenshot(metadata: CallMetadata, options: ScreenshotOptions & types.TimeoutOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run( progress => this._page.screenshotter.screenshotElement(progress, this, options), options.timeout); diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 49ddf5a5c9238..a096946457b69 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -419,7 +419,6 @@ export class FFPage implements PageDelegate { height: viewportRect!.height, }; } - progress.throwIfAborted(); const { data } = await this._session.send('Page.screenshot', { mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), clip: documentRect, diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 4d2d62df9ab23..5ce23ffb5cbe0 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1136,7 +1136,7 @@ export class Frame extends SdkObject { async rafrafTimeoutScreenshotElementWithProgress(progress: Progress, selector: string, timeout: number, options: ScreenshotOptions): Promise { return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, async handle => { - await handle._frame.rafrafTimeout(timeout); + await handle._frame.rafrafTimeout(progress, timeout); return await this._page.screenshotter.screenshotElement(progress, handle, options); }); } @@ -1484,63 +1484,67 @@ export class Frame extends SdkObject { return { matches, received }; } - async _waitForFunctionExpression(metadata: CallMetadata, expression: string, isFunction: boolean | undefined, arg: any, options: types.WaitForFunctionOptions, world: types.World = 'main'): Promise> { - const controller = new ProgressController(metadata, this); + async waitForFunctionExpression(metadata: CallMetadata, expression: string, isFunction: boolean | undefined, arg: any, options: types.WaitForFunctionOptions): Promise> { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.waitForFunctionExpressionImpl(progress, expression, isFunction, arg, options, 'main'), options.timeout); + } + + async waitForFunctionExpressionImpl(progress: Progress, expression: string, isFunction: boolean | undefined, arg: any, options: { pollingInterval?: number }, world: types.World = 'main'): Promise> { if (typeof options.pollingInterval === 'number') assert(options.pollingInterval > 0, 'Cannot poll with non-positive interval: ' + options.pollingInterval); expression = js.normalizeEvaluationExpression(expression, isFunction); - return controller.run(async progress => { - return this.retryWithProgressAndTimeouts(progress, [100], async () => { - const context = world === 'main' ? await this._mainContext() : await this._utilityContext(); - const injectedScript = await context.injectedScript(); - const handle = await injectedScript.evaluateHandle((injected, { expression, isFunction, polling, arg }) => { - const predicate = (): R => { - // NOTE: make sure to use `globalThis.eval` instead of `self.eval` due to a bug with sandbox isolation - // in firefox. - // See https://bugzilla.mozilla.org/show_bug.cgi?id=1814898 - let result = globalThis.eval(expression); - if (isFunction === true) { + return this.retryWithProgressAndTimeouts(progress, [100], async () => { + const context = world === 'main' ? await progress.race(this._mainContext()) : await progress.race(this._utilityContext()); + const injectedScript = await progress.race(context.injectedScript()); + const handle = await progress.race(injectedScript.evaluateHandle((injected, { expression, isFunction, polling, arg }) => { + const predicate = (): R => { + // NOTE: make sure to use `globalThis.eval` instead of `self.eval` due to a bug with sandbox isolation + // in firefox. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1814898 + let result = globalThis.eval(expression); + if (isFunction === true) { + result = result(arg); + } else if (isFunction === false) { + result = result; + } else { + // auto detect. + if (typeof result === 'function') result = result(arg); - } else if (isFunction === false) { - result = result; - } else { - // auto detect. - if (typeof result === 'function') - result = result(arg); - } - return result; - }; - - let fulfill: (result: R) => void; - let reject: (error: Error) => void; - let aborted = false; - const result = new Promise((f, r) => { fulfill = f; reject = r; }); - - const next = () => { - if (aborted) + } + return result; + }; + + let fulfill: (result: R) => void; + let reject: (error: Error) => void; + let aborted = false; + const result = new Promise((f, r) => { fulfill = f; reject = r; }); + + const next = () => { + if (aborted) + return; + try { + const success = predicate(); + if (success) { + fulfill(success); return; - try { - const success = predicate(); - if (success) { - fulfill(success); - return; - } - if (typeof polling !== 'number') - injected.utils.builtins.requestAnimationFrame(next); - else - injected.utils.builtins.setTimeout(next, polling); - } catch (e) { - reject(e); } - }; - - next(); - return { result, abort: () => aborted = true }; - }, { expression, isFunction, polling: options.pollingInterval, arg }); - progress.cleanupWhenAborted(() => handle.evaluate(h => h.abort()).catch(() => {})); - return handle.evaluateHandle(h => h.result); - }); - }, options.timeout); + if (typeof polling !== 'number') + injected.utils.builtins.requestAnimationFrame(next); + else + injected.utils.builtins.setTimeout(next, polling); + } catch (e) { + reject(e); + } + }; + + next(); + return { result, abort: () => aborted = true }; + }, { expression, isFunction, polling: options.pollingInterval, arg })); + progress.cleanupWhenAborted(() => handle.evaluate(h => h.abort()).finally(() => handle.dispose())); + const result = await progress.race(handle.evaluateHandle(h => h.result)); + handle.dispose(); + return result; + }); } async waitForFunctionValueInUtility(progress: Progress, pageFunction: js.Func1) { @@ -1550,7 +1554,7 @@ export class Frame extends SdkObject { return result; return JSON.stringify(result); }`; - const handle = await this._waitForFunctionExpression(serverSideCallMetadata(), expression, true, undefined, { timeout: progress.timeUntilDeadline() }, 'utility'); + const handle = await this.waitForFunctionExpressionImpl(progress, expression, true, undefined, {}, 'utility'); return JSON.parse(handle.rawValue()) as R; } @@ -1559,18 +1563,18 @@ export class Frame extends SdkObject { return context.evaluate(() => document.title); } - async rafrafTimeout(timeout: number): Promise { + async rafrafTimeout(progress: Progress, timeout: number): Promise { if (timeout === 0) return; - const context = await this._utilityContext(); + const context = await progress.race(this._utilityContext()); await Promise.all([ // wait for double raf - context.evaluate(() => new Promise(x => { + progress.race(context.evaluate(() => new Promise(x => { requestAnimationFrame(() => { requestAnimationFrame(x); }); - })), - new Promise(fulfill => setTimeout(fulfill, timeout)), + }))), + progress.wait(timeout), ]); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 7e56b07abccb5..162a2b6d91dda 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -595,12 +595,12 @@ export class Page extends SdkObject { return await locator.frame.rafrafTimeoutScreenshotElementWithProgress(progress, locator.selector, timeout, options || {}); } : async (progress: Progress, timeout: number) => { await this.performActionPreChecks(progress); - await this.mainFrame().rafrafTimeout(timeout); + await this.mainFrame().rafrafTimeout(progress, timeout); return await this.screenshotter.screenshotPage(progress, options || {}); }; const comparator = getComparator('image/png'); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); if (!options.expected && options.isNot) return { errorMessage: '"not" matcher requires expected result' }; try { @@ -636,7 +636,6 @@ export class Page extends SdkObject { progress.log(` generating new stable screenshot expectation`); let isFirstIteration = true; while (true) { - progress.throwIfAborted(); if (this.isClosed()) throw new Error('The page has closed'); const screenshotTimeout = pollIntervals.shift() ?? 1000; @@ -644,6 +643,8 @@ export class Page extends SdkObject { progress.log(`waiting ${screenshotTimeout}ms before taking screenshot`); previous = actual; actual = await rafrafScreenshot(progress, screenshotTimeout).catch(e => { + if (isAbortError(e)) + throw e; progress.log(`failed to take screenshot - ` + e.message); return undefined; }); diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index 30e8c9ae1f8da..306857ee2836e 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -206,7 +206,6 @@ export class Screenshotter { progress.log('taking page screenshot'); const viewportSize = await this._originalViewportSize(progress); await this._preparePageForScreenshot(progress, this._page.mainFrame(), options.style, options.caret !== 'initial', options.animations === 'disabled'); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. if (options.fullPage) { const fullPageSize = await this._fullPageSize(progress); @@ -215,14 +214,12 @@ export class Screenshotter { if (options.clip) documentRect = trimClipToSize(options.clip, documentRect); const buffer = await this._screenshot(progress, format, documentRect, undefined, fitsViewport, options); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. await this._restorePageAfterScreenshot(); return buffer; } const viewportRect = options.clip ? trimClipToSize(options.clip, viewportSize) : { x: 0, y: 0, ...viewportSize }; const buffer = await this._screenshot(progress, format, undefined, viewportRect, true, options); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. await this._restorePageAfterScreenshot(); return buffer; }); @@ -235,24 +232,19 @@ export class Screenshotter { const viewportSize = await this._originalViewportSize(progress); await this._preparePageForScreenshot(progress, handle._frame, options.style, options.caret !== 'initial', options.animations === 'disabled'); - progress.throwIfAborted(); // Do not do extra work. - await handle._waitAndScrollIntoViewIfNeeded(progress, true /* waitForVisible */); - progress.throwIfAborted(); // Do not do extra work. - const boundingBox = await handle.boundingBox(); + const boundingBox = await progress.race(handle.boundingBox()); assert(boundingBox, 'Node is either not visible or not an HTMLElement'); assert(boundingBox.width !== 0, 'Node has 0 width.'); assert(boundingBox.height !== 0, 'Node has 0 height.'); const fitsViewport = boundingBox.width <= viewportSize.width && boundingBox.height <= viewportSize.height; - progress.throwIfAborted(); // Avoid extra work. const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({ x: window.scrollX, y: window.scrollY })); const documentRect = { ...boundingBox }; documentRect.x += scrollOffset.x; documentRect.y += scrollOffset.y; const buffer = await this._screenshot(progress, format, helper.enclosingIntRect(documentRect), undefined, fitsViewport, options); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. await this._restorePageAfterScreenshot(); return buffer; }); @@ -262,13 +254,13 @@ export class Screenshotter { if (disableAnimations) progress.log(' disabled all CSS animations'); const syncAnimations = this._page.delegate.shouldToggleStyleSheetToSyncAnimations(); - await this._page.safeNonStallingEvaluateInAllFrames('(' + inPagePrepareForScreenshots.toString() + `)(${JSON.stringify(screenshotStyle)}, ${hideCaret}, ${disableAnimations}, ${syncAnimations})`, 'utility'); + progress.cleanupWhenAborted(() => this._restorePageAfterScreenshot()); + await progress.race(this._page.safeNonStallingEvaluateInAllFrames('(' + inPagePrepareForScreenshots.toString() + `)(${JSON.stringify(screenshotStyle)}, ${hideCaret}, ${disableAnimations}, ${syncAnimations})`, 'utility')); if (!process.env.PW_TEST_SCREENSHOT_NO_FONTS_READY) { progress.log('waiting for fonts to load...'); - await frame.nonStallingEvaluateInExistingContext('document.fonts.ready', 'utility').catch(() => {}); + await progress.race(frame.nonStallingEvaluateInExistingContext('document.fonts.ready', 'utility').catch(() => {})); progress.log('fonts loaded'); } - progress.cleanupWhenAborted(() => this._restorePageAfterScreenshot()); } async _restorePageAfterScreenshot() { @@ -276,6 +268,9 @@ export class Screenshotter { } async _maskElements(progress: Progress, options: ScreenshotOptions): Promise<() => Promise> { + if (!options.mask || !options.mask.length) + return () => Promise.resolve(); + const framesToParsedSelectors: MultiMap = new MultiMap(); const cleanup = async () => { @@ -283,50 +278,38 @@ export class Screenshotter { await frame.hideHighlight(); })); }; + progress.cleanupWhenAborted(cleanup); - if (!options.mask || !options.mask.length) - return cleanup; - - await Promise.all((options.mask || []).map(async ({ frame, selector }) => { + await progress.race(Promise.all((options.mask || []).map(async ({ frame, selector }) => { const pair = await frame.selectors.resolveFrameForSelector(selector); if (pair) framesToParsedSelectors.set(pair.frame, pair.info.parsed); - })); - progress.throwIfAborted(); // Avoid extra work. + }))); - await Promise.all([...framesToParsedSelectors.keys()].map(async frame => { + await progress.race(Promise.all([...framesToParsedSelectors.keys()].map(async frame => { await frame.maskSelectors(framesToParsedSelectors.get(frame), options.maskColor || '#F0F'); - })); - progress.cleanupWhenAborted(cleanup); + }))); return cleanup; } private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise { if ((options as any).__testHookBeforeScreenshot) - await (options as any).__testHookBeforeScreenshot(); - progress.throwIfAborted(); // Screenshotting is expensive - avoid extra work. + await progress.race((options as any).__testHookBeforeScreenshot()); const shouldSetDefaultBackground = options.omitBackground && format === 'png'; if (shouldSetDefaultBackground) { - await this._page.delegate.setBackgroundColor({ r: 0, g: 0, b: 0, a: 0 }); progress.cleanupWhenAborted(() => this._page.delegate.setBackgroundColor()); + await progress.race(this._page.delegate.setBackgroundColor({ r: 0, g: 0, b: 0, a: 0 })); } - progress.throwIfAborted(); // Avoid extra work. const cleanupHighlight = await this._maskElements(progress, options); - progress.throwIfAborted(); // Avoid extra work. - const quality = format === 'jpeg' ? options.quality ?? 80 : undefined; - const buffer = await this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device'); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. - + const buffer = await progress.race(this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device')); await cleanupHighlight(); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. if (shouldSetDefaultBackground) - await this._page.delegate.setBackgroundColor(); - progress.throwIfAborted(); // Avoid side effects. + await progress.race(this._page.delegate.setBackgroundColor()); if ((options as any).__testHookAfterScreenshot) - await (options as any).__testHookAfterScreenshot(); + await progress.race((options as any).__testHookAfterScreenshot()); return buffer; } } From 777d1e54b6f1389902ed25ead1c899fb4e1a6da7 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 10:10:54 +0200 Subject: [PATCH 48/71] chore: use different babel import in tsxTransform (#36370) --- packages/playwright-ct-core/src/tsxTransform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index f2fad4efa80bd..fbf3d0cc182cb 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -19,7 +19,7 @@ import path from 'path'; import { declare, traverse, types } from 'playwright/lib/transform/babelBundle'; import { setTransformData } from 'playwright/lib/transform/transform'; -import type { BabelAPI, PluginObj, T } from 'playwright/src/transform/babelBundle'; +import type { BabelAPI, PluginObj, T } from 'playwright/lib/transform/babelBundle'; const t: typeof T = types; let jsxComponentNames: Set; From 173b455941b6f0fd9320f53127f975d0cff63a90 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 10:22:19 +0200 Subject: [PATCH 49/71] fix(html-reporter): show filtered stats when filtering for labels/annots (#36368) --- packages/html-reporter/src/filter.ts | 5 ++++- tests/playwright-test/reporter-html.spec.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/html-reporter/src/filter.ts b/packages/html-reporter/src/filter.ts index 135336e869907..04034c8ceb3fc 100644 --- a/packages/html-reporter/src/filter.ts +++ b/packages/html-reporter/src/filter.ts @@ -29,7 +29,10 @@ export class Filter { annotations: FilterToken[] = []; empty(): boolean { - return this.project.length + this.status.length + this.text.length === 0; + return ( + this.project.length + this.status.length + this.text.length + + this.labels.length + this.annotations.length + ) === 0; } static parse(expression: string): Filter { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 83465d8d06bbf..4e9547e45b12b 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1933,6 +1933,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { const names = ['one foo', 'two foo', 'three bar', 'four bar', 'five baz']; for (const name of names) { test('b-' + name, async ({}) => { + test.info().annotations.push({ type: 'issue', description: 'test issue' }); expect(name).not.toContain('one'); await new Promise(f => setTimeout(f, 1100)); }); @@ -1988,6 +1989,9 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('.subnav-item:has-text("Failed") .counter')).toHaveText('3'); await expect(page.locator('.subnav-item:has-text("Flaky") .counter')).toHaveText('0'); await expect(page.locator('.subnav-item:has-text("Skipped") .counter')).toHaveText('0'); + + await searchInput.fill('annot:issue'); + await expect(page.getByTestId('filtered-tests-count')).toContainText(`Filtered: 5`); }); test('labels should be applied together with status filter', async ({ runInlineTest, showReport, page }) => { From 1357f0ab8300a27b756e2cb1c788ce3a5ad76760 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 10:00:21 +0100 Subject: [PATCH 50/71] chore: simplify bidi browsers handling (#36363) --- .../playwright-core/src/browserServerImpl.ts | 4 +-- .../playwright-core/src/client/playwright.ts | 4 +-- .../playwright-core/src/inProcessFactory.ts | 4 +-- .../playwright-core/src/protocol/validator.ts | 4 +-- .../src/remote/playwrightConnection.ts | 9 +----- .../src/server/bidi/bidiChromium.ts | 2 +- .../src/server/bidi/bidiFirefox.ts | 6 +++- .../dispatchers/playwrightDispatcher.ts | 17 +++------- .../playwright-core/src/server/playwright.ts | 8 ++--- .../src/server/registry/index.ts | 31 ++++++------------- packages/protocol/src/channels.d.ts | 4 +-- packages/protocol/src/protocol.yml | 4 +-- tests/bidi/playwright.config.ts | 2 +- tests/config/remoteServer.ts | 6 ---- tests/library/browsertype-connect.spec.ts | 4 +-- tests/library/har.spec.ts | 8 ++--- 16 files changed, 41 insertions(+), 76 deletions(-) diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index ee20e3a102d01..9a8b295202e40 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -32,9 +32,9 @@ import type { WebSocketEventEmitter } from './utilsBundle'; import type { Browser } from './server/browser'; export class BrowserServerLauncherImpl implements BrowserServerLauncher { - private _browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium'; + private _browserName: 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium'; - constructor(browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium') { + constructor(browserName: 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium') { this._browserName = browserName; } diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index fac9a671ed172..48c6349080f15 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -58,9 +58,9 @@ export class Playwright extends ChannelOwner { this._android._playwright = this; this._electron = Electron.from(initializer.electron); this._electron._playwright = this; - this._bidiChromium = BrowserType.from(initializer.bidiChromium); + this._bidiChromium = BrowserType.from(initializer._bidiChromium); this._bidiChromium._playwright = this; - this._bidiFirefox = BrowserType.from(initializer.bidiFirefox); + this._bidiFirefox = BrowserType.from(initializer._bidiFirefox); this._bidiFirefox._playwright = this; this.devices = this._connection.localUtils()?.devices ?? {}; this.selectors = new Selectors(this._connection._platform); diff --git a/packages/playwright-core/src/inProcessFactory.ts b/packages/playwright-core/src/inProcessFactory.ts index 0bb1d89d4b7b6..0dac70918cb08 100644 --- a/packages/playwright-core/src/inProcessFactory.ts +++ b/packages/playwright-core/src/inProcessFactory.ts @@ -42,8 +42,8 @@ export function createInProcessPlaywright(): PlaywrightAPI { playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox'); playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit'); playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl(); - playwrightAPI._bidiChromium._serverLauncher = new BrowserServerLauncherImpl('bidiChromium'); - playwrightAPI._bidiFirefox._serverLauncher = new BrowserServerLauncherImpl('bidiFirefox'); + playwrightAPI._bidiChromium._serverLauncher = new BrowserServerLauncherImpl('_bidiChromium'); + playwrightAPI._bidiFirefox._serverLauncher = new BrowserServerLauncherImpl('_bidiFirefox'); // Switch to async dispatch after we got Playwright object. dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message)); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 7e9073a25166d..2475526a43eb5 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -376,8 +376,8 @@ scheme.PlaywrightInitializer = tObject({ chromium: tChannel(['BrowserType']), firefox: tChannel(['BrowserType']), webkit: tChannel(['BrowserType']), - bidiChromium: tChannel(['BrowserType']), - bidiFirefox: tChannel(['BrowserType']), + _bidiChromium: tChannel(['BrowserType']), + _bidiFirefox: tChannel(['BrowserType']), android: tChannel(['Android']), electron: tChannel(['Electron']), utils: tOptional(tChannel(['LocalUtils'])), diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index f96eded9149f7..1a3dcb2a32327 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -118,14 +118,7 @@ export class PlaywrightConnection { private async _initLaunchBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { debugLogger.log('server', `[${this._id}] engaged launch mode for "${this._options.browserName}"`); const ownedSocksProxy = await this._createOwnedSocksProxy(); - let browserName = this._options.browserName; - if ('bidi' === browserName) { - if (this._options.launchOptions?.channel?.toLocaleLowerCase().includes('firefox')) - browserName = 'bidiFirefox'; - else - browserName = 'bidiChromium'; - } - const browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); + const browser = await this._playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); browser.options.sdkLanguage = options.sdkLanguage; this._cleanups.push(() => browser.close({ reason: 'Connection terminated' })); diff --git a/packages/playwright-core/src/server/bidi/bidiChromium.ts b/packages/playwright-core/src/server/bidi/bidiChromium.ts index 4347d0bcd62a1..38a23580c4e40 100644 --- a/packages/playwright-core/src/server/bidi/bidiChromium.ts +++ b/packages/playwright-core/src/server/bidi/bidiChromium.ts @@ -34,7 +34,7 @@ import type * as types from '../types'; export class BidiChromium extends BrowserType { constructor(parent: SdkObject) { - super(parent, 'bidi'); + super(parent, '_bidiChromium'); } override async connectToTransport(transport: ConnectionTransport, options: BrowserOptions, browserLogsCollector: RecentLogsCollector): Promise { diff --git a/packages/playwright-core/src/server/bidi/bidiFirefox.ts b/packages/playwright-core/src/server/bidi/bidiFirefox.ts index 28c2af7c1db86..f10808a13dd36 100644 --- a/packages/playwright-core/src/server/bidi/bidiFirefox.ts +++ b/packages/playwright-core/src/server/bidi/bidiFirefox.ts @@ -35,7 +35,11 @@ import type { RecentLogsCollector } from '../utils/debugLogger'; export class BidiFirefox extends BrowserType { constructor(parent: SdkObject) { - super(parent, 'bidi'); + super(parent, '_bidiFirefox'); + } + + override executablePath(): string { + return ''; } override async connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 74ad4b4ab06f8..04bb144d1176a 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -52,15 +52,15 @@ export class PlaywrightDispatcher extends Dispatcher(); @@ -65,8 +65,8 @@ export class Playwright extends SdkObject { } }, null); this.chromium = new Chromium(this); - this.bidiChromium = new BidiChromium(this); - this.bidiFirefox = new BidiFirefox(this); + this._bidiChromium = new BidiChromium(this); + this._bidiFirefox = new BidiFirefox(this); this.firefox = new Firefox(this); this.webkit = new WebKit(this); this.electron = new Electron(this); diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 1a6f5f2f2b177..4a0e7cb989be7 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -384,7 +384,9 @@ const DOWNLOAD_PATHS: Record = { 'win64': 'builds/android/%s/android.zip', }, // TODO(bidi): implement downloads. - 'bidi': { + '_bidiFirefox': { + } as DownloadPaths, + '_bidiChromium': { } as DownloadPaths, }; @@ -480,7 +482,7 @@ function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] { }); } -export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; +export type BrowserName = 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium'; type InternalTool = 'ffmpeg' | 'winldd' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android'; type BidiChannel = 'moz-firefox' | 'moz-firefox-beta' | 'moz-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; @@ -717,8 +719,8 @@ export class Registry { })); this._executables.push({ type: 'browser', - name: 'bidi-chromium', - browserName: 'bidi', + name: '_bidiChromium', + browserName: '_bidiChromium', directory: chromium.dir, executablePath: () => chromiumExecutable, executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium', chromiumExecutable, chromium.installByDefault, sdkLanguage), @@ -842,21 +844,6 @@ export class Registry { _dependencyGroup: 'tools', _isHermeticInstallation: true, }); - - this._executables.push({ - type: 'browser', - name: 'bidi', - browserName: 'bidi', - directory: undefined, - executablePath: () => undefined, - executablePathOrDie: () => '', - installType: 'none', - _validateHostRequirements: () => Promise.resolve(), - downloadURLs: [], - _install: () => Promise.resolve(), - _dependencyGroup: 'tools', - _isHermeticInstallation: true, - }); } private _createChromiumChannel(name: ChromiumChannel, lookAt: Record<'linux' | 'darwin' | 'win32', string>, install?: () => Promise): ExecutableImpl { @@ -931,7 +918,7 @@ export class Registry { return { type: 'channel', name, - browserName: 'bidi', + browserName: '_bidiFirefox', directory: undefined, executablePath: (sdkLanguage: string) => executablePath(sdkLanguage, false), executablePathOrDie: (sdkLanguage: string) => executablePath(sdkLanguage, true)!, @@ -947,7 +934,7 @@ export class Registry { const suffix = lookAt[process.platform as 'linux' | 'darwin' | 'win32']; if (!suffix) { if (shouldThrow) - throw new Error(`Firefox distribution '${name}' is not supported on ${process.platform}`); + throw new Error(`Chromium distribution '${name}' is not supported on ${process.platform}`); return undefined; } const prefixes = (process.platform === 'win32' ? [ @@ -974,7 +961,7 @@ export class Registry { return { type: 'channel', name, - browserName: 'bidi', + browserName: '_bidiChromium', directory: undefined, executablePath: (sdkLanguage: string) => executablePath(sdkLanguage, false), executablePathOrDie: (sdkLanguage: string) => executablePath(sdkLanguage, true)!, diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 1afeaeec8b835..3e052e3d5726e 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -624,8 +624,8 @@ export type PlaywrightInitializer = { chromium: BrowserTypeChannel, firefox: BrowserTypeChannel, webkit: BrowserTypeChannel, - bidiChromium: BrowserTypeChannel, - bidiFirefox: BrowserTypeChannel, + _bidiChromium: BrowserTypeChannel, + _bidiFirefox: BrowserTypeChannel, android: AndroidChannel, electron: ElectronChannel, utils?: LocalUtilsChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c33c381bdfdcc..e9c765d045421 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -791,8 +791,8 @@ Playwright: chromium: BrowserType firefox: BrowserType webkit: BrowserType - bidiChromium: BrowserType - bidiFirefox: BrowserType + _bidiChromium: BrowserType + _bidiFirefox: BrowserType android: Android electron: Electron utils: LocalUtils? diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index d650233e7ceb2..a26b72bd53e8b 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -106,7 +106,7 @@ for (const [key, channels] of Object.entries(browserToChannels)) { use: { browserName, headless: !headed, - channel, + channel: channel === 'bidi-chromium' ? undefined : channel, video: 'off', launchOptions: { executablePath, diff --git a/tests/config/remoteServer.ts b/tests/config/remoteServer.ts index 94b252a86c838..4c25934581abc 100644 --- a/tests/config/remoteServer.ts +++ b/tests/config/remoteServer.ts @@ -103,12 +103,6 @@ export class RemoteServer implements PlaywrightServer { launchOptions, ...remoteServerOptions, }; - if ('bidi' === browserType.name()) { - if (channel.toLocaleLowerCase().includes('firefox')) - options.browserTypeName = '_bidiFirefox'; - else - options.browserTypeName = '_bidiChromium'; - } this._process = childProcess({ command: ['node', path.join(__dirname, 'remote-server-impl.js'), JSON.stringify(options)], }); diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index 80653941e60b6..1eb1342e8d5b5 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -258,9 +258,7 @@ for (const kind of ['launchServer', 'run-server'] as const) { }).catch(() => {}) ]); expect(request.headers['user-agent']).toBe(getUserAgent()); - // _bidiFirefox and _bidiChromium are initialized with 'bidi' as browser name. - const bidiAwareBrowserName = browserName.startsWith('_bidi') ? 'bidi' : browserName; - expect(request.headers['x-playwright-browser']).toBe(bidiAwareBrowserName); + expect(request.headers['x-playwright-browser']).toBe(browserName); expect(request.headers['foo']).toBe('bar'); }); diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 82c1148289ed1..db5b14a5d135e 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -56,9 +56,7 @@ it('should have browser', async ({ browserName, browser, contextFactory, server await page.goto(server.EMPTY_PAGE); const log = await getLog(); - // _bidiFirefox and _bidiChromium are initialized with 'bidi' as browser name. - const harBrowserName = browserName.startsWith('_bidi') ? 'bidi' : browserName; - expect(log.browser!.name.toLowerCase()).toBe(harBrowserName); + expect(log.browser!.name.toLowerCase()).toBe(browserName); expect(log.browser!.version).toBe(browser.version()); }); @@ -915,8 +913,6 @@ it('should not hang on slow chunked response', async ({ browserName, browser, co await page.evaluate(() => (window as any).receivedFirstData); const log = await getLog(); - // _bidiFirefox and _bidiChromium are initialized with 'bidi' as browser name. - const harBrowserName = browserName.startsWith('_bidi') ? 'bidi' : browserName; - expect(log.browser!.name.toLowerCase()).toBe(harBrowserName); + expect(log.browser!.name.toLowerCase()).toBe(browserName); expect(log.browser!.version).toBe(browser.version()); }); From 84c69edbb85422820551403a9e0e76b4dcc26573 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 10:00:34 +0100 Subject: [PATCH 51/71] chore: make launch, newContext and newPage progress "strict" (#36336) --- .../playwright-core/src/server/browser.ts | 27 +++---- .../src/server/browserContext.ts | 71 ++++++++++--------- .../playwright-core/src/server/browserType.ts | 54 ++++++++------ .../src/server/chromium/chromium.ts | 13 ++-- .../src/server/debugController.ts | 2 +- .../dispatchers/browserContextDispatcher.ts | 2 +- .../server/dispatchers/browserDispatcher.ts | 4 +- .../server/dispatchers/electronDispatcher.ts | 5 +- .../dispatchers/localUtilsDispatcher.ts | 3 +- .../src/server/electron/electron.ts | 34 ++++----- .../socksClientCertificatesInterceptor.ts | 13 ++-- .../playwright-core/src/server/transport.ts | 11 +-- .../src/server/utils/processLauncher.ts | 2 +- tests/library/browser.spec.ts | 8 +++ 14 files changed, 136 insertions(+), 113 deletions(-) diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index fc2fcccdf7407..d985e96fa04b0 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -20,6 +20,7 @@ import { Download } from './download'; import { SdkObject } from './instrumentation'; import { Page } from './page'; import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; +import { ProgressController } from './progress'; import type { CallMetadata } from './instrumentation'; import type * as types from './types'; @@ -28,6 +29,7 @@ import type { RecentLogsCollector } from './utils/debugLogger'; import type * as channels from '@protocol/channels'; import type { ChildProcess } from 'child_process'; import type { Language } from '../utils'; +import type { Progress } from './progress'; export interface BrowserProcess { @@ -90,25 +92,26 @@ export abstract class Browser extends SdkObject { return this.options.sdkLanguage || this.attribution.playwright.options.sdkLanguage; } - async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { + newContextFromMetadata(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.newContext(progress, options)); + } + + async newContext(progress: Progress, options: types.BrowserContextOptions): Promise { validateBrowserContextOptions(options, this.options); let clientCertificatesProxy: ClientCertificatesProxy | undefined; if (options.clientCertificates?.length) { - clientCertificatesProxy = new ClientCertificatesProxy(options); + clientCertificatesProxy = await progress.raceWithCleanup(ClientCertificatesProxy.create(options), proxy => proxy.close()); options = { ...options }; - options.proxyOverride = await clientCertificatesProxy.listen(); + options.proxyOverride = clientCertificatesProxy.proxySettings(); options.internalIgnoreHTTPSErrors = true; } - let context; - try { - context = await this.doCreateNewContext(options); - } catch (error) { - await clientCertificatesProxy?.close(); - throw error; - } + const context = await progress.raceWithCleanup(this.doCreateNewContext(options), context => context.close({ reason: 'Failed to create context' })); context._clientCertificatesProxy = clientCertificatesProxy; + if ((options as any).__testHookBeforeSetStorageState) + await progress.race((options as any).__testHookBeforeSetStorageState()); if (options.storageState) - await context.setStorageState(metadata, options.storageState); + await context.setStorageState(progress, options.storageState); this.emit(Browser.Events.Context, context); return context; } @@ -118,7 +121,7 @@ export abstract class Browser extends SdkObject { if (!this._contextForReuse || hash !== this._contextForReuse.hash || !this._contextForReuse.context.canResetForReuse()) { if (this._contextForReuse) await this._contextForReuse.context.close({ reason: 'Context reused' }); - this._contextForReuse = { context: await this.newContext(metadata, params), hash }; + this._contextForReuse = { context: await this.newContextFromMetadata(metadata, params), hash }; return { context: this._contextForReuse.context, needsReset: false }; } await this._contextForReuse.context.stopPendingOperations('Context recreated'); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 5bd2577dad98f..0a5b7eac3aab5 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -218,7 +218,7 @@ export abstract class BrowserContext extends SdkObject { // Navigate to about:blank first to ensure no page scripts are running after this point. await page?.mainFrame().gotoImpl(progress, 'about:blank', {}); - await this._resetStorage(); + await this._resetStorage(progress); await this.clock.resetForReuse(); // TODO: following can be optimized to not perform noops. if (this._options.permissions) @@ -379,14 +379,13 @@ export abstract class BrowserContext extends SdkObject { async _loadDefaultContextAsIs(progress: Progress): Promise { if (!this.possiblyUninitializedPages().length) { const waitForEvent = helper.waitForEvent(progress, this, BrowserContext.Events.Page); - progress.cleanupWhenAborted(() => waitForEvent.dispose); // Race against BrowserContext.close await Promise.race([waitForEvent.promise, this._closePromise]); } const page = this.possiblyUninitializedPages()[0]; if (!page) return; - const pageOrError = await page.waitForInitializedOrError(); + const pageOrError = await progress.race(page.waitForInitializedOrError()); if (pageOrError instanceof Error) throw pageOrError; await page.mainFrame()._waitForLoadState(progress, 'load'); @@ -402,7 +401,7 @@ export abstract class BrowserContext extends SdkObject { // Workaround for: // - chromium fails to change isMobile for existing page; // - webkit fails to change locale for existing page. - await this.newPage(progress.metadata); + await this.newPage(progress, false); await defaultPage.close(); } } @@ -511,9 +510,14 @@ export abstract class BrowserContext extends SdkObject { await this._closePromise; } - async newPage(metadata: CallMetadata): Promise { - const page = await this.doCreateNewPage(metadata.isServerSide); - const pageOrError = await page.waitForInitializedOrError(); + newPageFromMetadata(metadata: CallMetadata): Promise { + const contoller = new ProgressController(metadata, this, 'strict'); + return contoller.run(progress => this.newPage(progress, false)); + } + + async newPage(progress: Progress, isServerSide: boolean): Promise { + const page = await progress.raceWithCleanup(this.doCreateNewPage(isServerSide), page => page.close()); + const pageOrError = await progress.race(page.waitForInitializedOrError()); if (pageOrError instanceof Page) { if (pageOrError.isClosed()) throw new Error('Page has been closed.'); @@ -526,7 +530,12 @@ export abstract class BrowserContext extends SdkObject { this._origins.add(origin); } - async storageState(indexedDB = false): Promise { + storageState(indexedDB = false): Promise { + const controller = new ProgressController(serverSideCallMetadata(), this, 'strict'); + return controller.run(progress => this.storageStateImpl(progress, indexedDB)); + } + + async storageStateImpl(progress: Progress, indexedDB: boolean): Promise { const result: channels.BrowserContextStorageStateResult = { cookies: await this.cookies(), origins: [] @@ -557,15 +566,14 @@ export abstract class BrowserContext extends SdkObject { // If there are still origins to save, create a blank page to iterate over origins. if (originsToSave.size) { - const internalMetadata = serverSideCallMetadata(); - const page = await this.newPage(internalMetadata); - page.addRequestInterceptor(route => { + const page = await this.newPage(progress, true); + await progress.race(page.addRequestInterceptor(route => { route.fulfill({ body: '' }).catch(() => {}); - }, 'prepend'); + }, 'prepend')); for (const origin of originsToSave) { const frame = page.mainFrame(); - await frame.goto(internalMetadata, origin, { timeout: 0 }); - const storage: SerializedStorage = await frame.evaluateExpression(collectScript, { world: 'utility' }); + await frame.gotoImpl(progress, origin, {}); + const storage: SerializedStorage = await progress.race(frame.evaluateExpression(collectScript, { world: 'utility' })); if (storage.localStorage.length || storage.indexedDB?.length) result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); } @@ -574,29 +582,27 @@ export abstract class BrowserContext extends SdkObject { return result; } - async _resetStorage() { + async _resetStorage(progress: Progress) { const oldOrigins = this._origins; const newOrigins = new Map(this._options.storageState?.origins?.map(p => [p.origin, p]) || []); if (!oldOrigins.size && !newOrigins.size) return; let page = this.pages()[0]; - const internalMetadata = serverSideCallMetadata(); - page = page || await this.newPage({ - ...internalMetadata, - // Do not mark this page as internal, because we will leave it for later reuse - // as a user-visible page. - isServerSide: false, - }); + // Do not mark this page as internal, because we will leave it for later reuse + // as a user-visible page. + page = page || await this.newPage(progress, false); const interceptor = (route: network.Route) => { route.fulfill({ body: '' }).catch(() => {}); }; - await page.addRequestInterceptor(interceptor, 'prepend'); + + progress.cleanupWhenAborted(() => page.removeRequestInterceptor(interceptor)); + await progress.race(page.addRequestInterceptor(interceptor, 'prepend')); for (const origin of new Set([...oldOrigins, ...newOrigins.keys()])) { const frame = page.mainFrame(); - await frame.goto(internalMetadata, origin, { timeout: 0 }); - await frame.resetStorageForCurrentOriginBestEffort(newOrigins.get(origin)); + await frame.gotoImpl(progress, origin, {}); + await progress.race(frame.resetStorageForCurrentOriginBestEffort(newOrigins.get(origin))); } await page.removeRequestInterceptor(interceptor); @@ -615,27 +621,26 @@ export abstract class BrowserContext extends SdkObject { return this._settingStorageState; } - async setStorageState(metadata: CallMetadata, state: NonNullable) { + async setStorageState(progress: Progress, state: NonNullable) { this._settingStorageState = true; try { if (state.cookies) - await this.addCookies(state.cookies); + await progress.race(this.addCookies(state.cookies)); if (state.origins && state.origins.length) { - const internalMetadata = serverSideCallMetadata(); - const page = await this.newPage(internalMetadata); - await page.addRequestInterceptor(route => { + const page = await this.newPage(progress, true); + await progress.race(page.addRequestInterceptor(route => { route.fulfill({ body: '' }).catch(() => {}); - }, 'prepend'); + }, 'prepend')); for (const originState of state.origins) { const frame = page.mainFrame(); - await frame.goto(metadata, originState.origin, { timeout: 0 }); + await frame.gotoImpl(progress, originState.origin, {}); const restoreScript = `(() => { const module = {}; ${rawStorageSource.source} const script = new (module.exports.StorageScript())(${this._browser.options.name === 'firefox'}); return script.restore(${JSON.stringify(originState)}); })()`; - await frame.evaluateExpression(restoreScript, { world: 'utility' }); + await progress.race(frame.evaluateExpression(restoreScript, { world: 'utility' })); } await page.close(); } diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 5882687e90f1c..9febf7367aa82 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -23,7 +23,7 @@ import { debugMode } from './utils/debug'; import { assert } from '../utils/isomorphic/assert'; import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { DEFAULT_PLAYWRIGHT_TIMEOUT } from '../utils/isomorphic/time'; -import { existsAsync } from './utils/fileUtils'; +import { existsAsync, removeFolders } from './utils/fileUtils'; import { helper } from './helper'; import { SdkObject } from './instrumentation'; import { PipeTransport } from './pipeTransport'; @@ -69,7 +69,7 @@ export abstract class BrowserType extends SdkObject { async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise { options = this._validateLaunchOptions(options); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); const browser = await controller.run(progress => { const seleniumHubUrl = (options as any).__testHookSeleniumRemoteURL || process.env.SELENIUM_REMOTE_URL; if (seleniumHubUrl) @@ -81,17 +81,16 @@ export abstract class BrowserType extends SdkObject { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { timeout: number, cdpPort?: number, internalIgnoreHTTPSErrors?: boolean, socksProxyPort?: number }): Promise { const launchOptions = this._validateLaunchOptions(options); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); const browser = await controller.run(async progress => { // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. let clientCertificatesProxy: ClientCertificatesProxy | undefined; if (options.clientCertificates?.length) { - clientCertificatesProxy = new ClientCertificatesProxy(options); - launchOptions.proxyOverride = await clientCertificatesProxy?.listen(); + clientCertificatesProxy = await progress.raceWithCleanup(ClientCertificatesProxy.create(options), proxy => proxy.close()); + launchOptions.proxyOverride = clientCertificatesProxy.proxySettings(); options = { ...options }; options.internalIgnoreHTTPSErrors = true; } - progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; return browser; @@ -118,7 +117,7 @@ export abstract class BrowserType extends SdkObject { const browserLogsCollector = new RecentLogsCollector(); const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir); if ((options as any).__testHookBeforeCreateBrowser) - await (options as any).__testHookBeforeCreateBrowser(); + await progress.race((options as any).__testHookBeforeCreateBrowser()); const browserOptions: BrowserOptions = { name: this._name, isChromium: this._name === 'chromium', @@ -140,7 +139,7 @@ export abstract class BrowserType extends SdkObject { if (persistent) validateBrowserContextOptions(persistent, browserOptions); copyTestHooks(options, browserOptions); - const browser = await this.connectToTransport(transport, browserOptions, browserLogsCollector); + const browser = await progress.race(this.connectToTransport(transport, browserOptions, browserLogsCollector)); (browser as any)._userDataDirForTest = userDataDir; // We assume no control when using custom arguments, and do not prepare the default context in that case. if (persistent && !options.ignoreAllDefaultArgs) @@ -148,22 +147,16 @@ export abstract class BrowserType extends SdkObject { return browser; } - private async _launchProcess(progress: Progress, options: types.LaunchOptions, isPersistent: boolean, browserLogsCollector: RecentLogsCollector, userDataDir?: string): Promise<{ browserProcess: BrowserProcess, artifactsDir: string, userDataDir: string, transport: ConnectionTransport }> { + private async _prepareToLaunch(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string | undefined) { const { ignoreDefaultArgs, ignoreAllDefaultArgs, args = [], executablePath = null, - handleSIGINT = true, - handleSIGTERM = true, - handleSIGHUP = true, } = options; - - const env = options.env ? envArrayToObject(options.env) : process.env; - await this._createArtifactDirs(options); - const tempDirectories = []; + const tempDirectories: string[] = []; const artifactsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-artifacts-')); tempDirectories.push(artifactsDir); @@ -178,7 +171,7 @@ export abstract class BrowserType extends SdkObject { } await this.prepareUserDataDir(options, userDataDir); - const browserArguments = []; + const browserArguments: string[] = []; if (ignoreAllDefaultArgs) browserArguments.push(...args); else if (ignoreDefaultArgs) @@ -199,15 +192,29 @@ export abstract class BrowserType extends SdkObject { await registry.validateHostRequirementsForExecutablesIfNeeded([registryExecutable], this.attribution.playwright.options.sdkLanguage); } + return { executable, browserArguments, userDataDir, artifactsDir, tempDirectories }; + } + + private async _launchProcess(progress: Progress, options: types.LaunchOptions, isPersistent: boolean, browserLogsCollector: RecentLogsCollector, userDataDir?: string): Promise<{ browserProcess: BrowserProcess, artifactsDir: string, userDataDir: string, transport: ConnectionTransport }> { + const { + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + } = options; + + const env = options.env ? envArrayToObject(options.env) : process.env; + const prepared = await progress.race(this._prepareToLaunch(options, isPersistent, userDataDir)); + progress.cleanupWhenAborted(() => removeFolders(prepared.tempDirectories)); + // Note: it is important to define these variables before launchProcess, so that we don't get // "Cannot access 'browserServer' before initialization" if something went wrong. let transport: ConnectionTransport | undefined = undefined; let browserProcess: BrowserProcess | undefined = undefined; const exitPromise = new ManualPromise(); const { launchedProcess, gracefullyClose, kill } = await launchProcess({ - command: executable, - args: browserArguments, - env: this.amendEnvironment(env, userDataDir, executable, browserArguments), + command: prepared.executable, + args: prepared.browserArguments, + env: this.amendEnvironment(env, prepared.userDataDir, prepared.executable, prepared.browserArguments), handleSIGINT, handleSIGTERM, handleSIGHUP, @@ -216,7 +223,7 @@ export abstract class BrowserType extends SdkObject { browserLogsCollector.log(message); }, stdio: 'pipe', - tempDirectories, + tempDirectories: prepared.tempDirectories, attemptToGracefullyClose: async () => { if ((options as any).__testHookGracefullyClose) await (options as any).__testHookGracefullyClose(); @@ -253,7 +260,7 @@ export abstract class BrowserType extends SdkObject { kill }; progress.cleanupWhenAborted(() => closeOrKill(progress.timeUntilDeadline())); - const { wsEndpoint } = await Promise.race([ + const { wsEndpoint } = await progress.race([ this.waitForReadyState(options, browserLogsCollector), exitPromise.then(() => ({ wsEndpoint: undefined })), ]); @@ -263,7 +270,8 @@ export abstract class BrowserType extends SdkObject { const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream]; transport = new PipeTransport(stdio[3], stdio[4]); } - return { browserProcess, artifactsDir, userDataDir, transport }; + progress.cleanupWhenAborted(() => transport.close()); + return { browserProcess, artifactsDir: prepared.artifactsDir, userDataDir: prepared.userDataDir, transport }; } async _createArtifactDirs(options: types.LaunchOptions): Promise { diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index 075e56a4690e9..b35fbb83f0828 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -63,7 +63,7 @@ export class Chromium extends BrowserType { } override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray, timeout: number }) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return await this._connectOverCDPInternal(progress, endpointURL, options); }, options.timeout); @@ -79,12 +79,11 @@ export class Chromium extends BrowserType { else if (headersMap && !Object.keys(headersMap).some(key => key.toLowerCase() === 'user-agent')) headersMap['User-Agent'] = getUserAgent(); - const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); + const artifactsDir = await progress.race(fs.promises.mkdtemp(ARTIFACTS_FOLDER)); const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap); - progress.throwIfAborted(); - const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: headersMap }); + progress.cleanupWhenAborted(() => chromeTransport.close()); const cleanedUp = new ManualPromise(); const doCleanup = async () => { await removeFolders([artifactsDir]); @@ -111,8 +110,7 @@ export class Chromium extends BrowserType { originalLaunchOptions: { timeout: options.timeout }, }; validateBrowserContextOptions(persistent, browserOptions); - progress.throwIfAborted(); - const browser = await CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions); + const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions)); browser._isCollocatedWithServer = false; browser.on(Browser.Events.Disconnected, doCleanup); return browser; @@ -174,7 +172,7 @@ export class Chromium extends BrowserType { } override async _launchWithSeleniumHub(progress: Progress, hubUrl: string, options: types.LaunchOptions): Promise { - await this._createArtifactDirs(options); + await progress.race(this._createArtifactDirs(options)); if (!hubUrl.endsWith('/')) hubUrl = hubUrl + '/'; @@ -390,6 +388,7 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: const json = await fetchData({ url: httpURL, headers, + timeout: progress.timeUntilDeadline(), }, async (_, resp) => new Error(`Unexpected status ${resp.statusCode} when connecting to ${httpURL}.\n` + `This does not look like a DevTools server, try connecting via ws://.`) ); diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 9338b59406dd5..92b94211a612f 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -106,7 +106,7 @@ export class DebugController extends SdkObject { if (!pages.length) { const [browser] = this._playwright.allBrowsers(); const { context } = await browser.newContextForReuse({}, internalMetadata); - await context.newPage(internalMetadata); + await context.newPageFromMetadata(internalMetadata); } // Update test id attribute. if (params.testIdAttributeName) { diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 033efda435e5d..9609d62a26730 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -245,7 +245,7 @@ export class BrowserContextDispatcher extends Dispatcher { - return { page: PageDispatcher.from(this, await this._context.newPage(metadata)) }; + return { page: PageDispatcher.from(this, await this._context.newPageFromMetadata(metadata)) }; } async cookies(params: channels.BrowserContextCookiesParams): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts index 186ec73faa03c..448440069a939 100644 --- a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts @@ -60,14 +60,14 @@ export class BrowserDispatcher extends Dispatcher { if (!this._options.isolateContexts) { - const context = await this._object.newContext(metadata, params); + const context = await this._object.newContextFromMetadata(metadata, params); const contextDispatcher = BrowserContextDispatcher.from(this, context); return { context: contextDispatcher }; } if (params.recordVideo) params.recordVideo.dir = this._object.options.artifactsDir; - const context = await this._object.newContext(metadata, params); + const context = await this._object.newContextFromMetadata(metadata, params); this._isolatedContexts.add(context); context.on(BrowserContext.Events.Close, () => this._isolatedContexts.delete(context)); const contextDispatcher = BrowserContextDispatcher.from(this, context); diff --git a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts index 7660526176980..8ca84caadeca0 100644 --- a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts @@ -24,6 +24,7 @@ import type { PageDispatcher } from './pageDispatcher'; import type { ConsoleMessage } from '../console'; import type { Electron } from '../electron/electron'; import type * as channels from '@protocol/channels'; +import type { CallMetadata } from '@protocol/callMetadata'; export class ElectronDispatcher extends Dispatcher implements channels.ElectronChannel { @@ -35,10 +36,10 @@ export class ElectronDispatcher extends Dispatcher { + async launch(params: channels.ElectronLaunchParams, metadata: CallMetadata): Promise { if (this._denyLaunch) throw new Error(`Launching more browsers is not allowed.`); - const electronApplication = await this._object.launch(params); + const electronApplication = await this._object.launch(metadata, params); return { electronApplication: new ElectronApplicationDispatcher(this, electronApplication) }; } } diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 1a00d8c7f1f82..e614aae6219ef 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -83,7 +83,7 @@ export class LocalUtilsDispatcher extends Dispatcher { - const controller = new ProgressController(metadata, this._object); + const controller = new ProgressController(metadata, this._object, 'strict'); return await controller.run(async progress => { const wsHeaders = { 'User-Agent': getUserAgent(), @@ -146,7 +146,6 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + `This does not look like a Playwright server, try connecting via ws://.`); }); - progress.throwIfAborted(); const wsUrl = new URL(endpointURL); let wsEndpointPath = JSON.parse(json).wsEndpointPath; diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index b0c154effa1ad..d687571d47973 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -19,7 +19,7 @@ import os from 'os'; import path from 'path'; import * as readline from 'readline'; -import { ManualPromise } from '../../utils'; +import { ManualPromise, removeFolders } from '../../utils'; import { wrapInASCIIBox } from '../utils/ascii'; import { RecentLogsCollector } from '../utils/debugLogger'; import { eventsHelper } from '../utils/eventsHelper'; @@ -30,7 +30,7 @@ import { createHandle, CRExecutionContext } from '../chromium/crExecutionContext import { toConsoleMessageLocation } from '../chromium/crProtocolHelper'; import { ConsoleMessage } from '../console'; import { helper } from '../helper'; -import { SdkObject, serverSideCallMetadata } from '../instrumentation'; +import { CallMetadata, SdkObject } from '../instrumentation'; import * as js from '../javascript'; import { envArrayToObject, launchProcess } from '../utils/processLauncher'; import { ProgressController } from '../progress'; @@ -152,18 +152,15 @@ export class ElectronApplication extends SdkObject { export class Electron extends SdkObject { constructor(playwright: Playwright) { super(playwright, 'electron'); + this.logName = 'browser'; } - async launch(options: channels.ElectronLaunchParams): Promise { - const { - args = [], - } = options; - const controller = new ProgressController(serverSideCallMetadata(), this); - controller.setLogName('browser'); + async launch(metadata: CallMetadata, options: channels.ElectronLaunchParams): Promise { + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { let app: ElectronApplication | undefined = undefined; // --remote-debugging-port=0 must be the last playwright's argument, loader.ts relies on it. - let electronArguments = ['--inspect=0', '--remote-debugging-port=0', ...args]; + let electronArguments = ['--inspect=0', '--remote-debugging-port=0', ...(options.args || [])]; if (os.platform() === 'linux') { const runningAsRoot = process.geteuid && process.geteuid() === 0; @@ -171,7 +168,8 @@ export class Electron extends SdkObject { electronArguments.unshift('--no-sandbox'); } - const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); + const artifactsDir = await progress.race(fs.promises.mkdtemp(ARTIFACTS_FOLDER)); + progress.cleanupWhenAborted(() => removeFolders([artifactsDir])); const browserLogsCollector = new RecentLogsCollector(); const env = options.env ? envArrayToObject(options.env) : process.env; @@ -233,8 +231,8 @@ export class Electron extends SdkObject { // All waitForLines must be started immediately. // Otherwise the lines might come before we are ready. - const waitForXserverError = new Promise(async (resolve, reject) => { - waitForLine(progress, launchedProcess, /Unable to open X display/).then(() => reject(new Error([ + const waitForXserverError = waitForLine(progress, launchedProcess, /Unable to open X display/).then(() => { + throw new Error([ 'Unable to open X display!', `================================`, 'Most likely this is because there is no X server available.', @@ -242,7 +240,7 @@ export class Electron extends SdkObject { "For example: 'xvfb-run npm run test:e2e'", `================================`, progress.metadata.log - ].join('\n')))).catch(() => {}); + ].join('\n')); }); const nodeMatchPromise = waitForLine(progress, launchedProcess, /^Debugger listening on (ws:\/\/.*)$/); const chromeMatchPromise = waitForLine(progress, launchedProcess, /^DevTools listening on (ws:\/\/.*)$/); @@ -250,6 +248,7 @@ export class Electron extends SdkObject { const nodeMatch = await nodeMatchPromise; const nodeTransport = await WebSocketTransport.connect(progress, nodeMatch[1]); + progress.cleanupWhenAborted(() => nodeTransport.close()); const nodeConnection = new CRConnection(this, nodeTransport, helper.debugProtocolLogger(), browserLogsCollector); // Immediately release exiting process under debug. @@ -261,6 +260,7 @@ export class Electron extends SdkObject { waitForXserverError, ]) as RegExpMatchArray; const chromeTransport = await WebSocketTransport.connect(progress, chromeMatch[1]); + progress.cleanupWhenAborted(() => chromeTransport.close()); const browserProcess: BrowserProcess = { onclose: undefined, process: launchedProcess, @@ -285,16 +285,16 @@ export class Electron extends SdkObject { originalLaunchOptions: { timeout: options.timeout }, }; validateBrowserContextOptions(contextOptions, browserOptions); - const browser = await CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions); + const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions)); app = new ElectronApplication(this, browser, nodeConnection, launchedProcess); - await app.initialize(); + await progress.race(app.initialize()); return app; }, options.timeout); } } function waitForLine(progress: Progress, process: childProcess.ChildProcess, regex: RegExp): Promise { - return new Promise((resolve, reject) => { + return progress.race(new Promise((resolve, reject) => { const rl = readline.createInterface({ input: process.stderr! }); const failError = new Error('Process failed to launch!'); const listeners = [ @@ -318,5 +318,5 @@ function waitForLine(progress: Progress, process: childProcess.ChildProcess, reg function cleanup() { eventsHelper.removeEventListeners(listeners); } - }); + })); } diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 2cb588378208b..896dd2f091d8b 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -244,7 +244,7 @@ export class ClientCertificatesProxy { alpnCache: ALPNCache; proxyAgentFromOptions: ReturnType; - constructor( + private constructor( contextOptions: Pick ) { verifyClientCertificates(contextOptions.clientCertificates); @@ -294,9 +294,14 @@ export class ClientCertificatesProxy { } } - public async listen() { - const port = await this._socksProxy.listen(0, '127.0.0.1'); - return { server: `socks5://127.0.0.1:${port}` }; + public static async create(contextOptions: Pick) { + const proxy = new ClientCertificatesProxy(contextOptions); + await proxy._socksProxy.listen(0, '127.0.0.1'); + return proxy; + } + + public proxySettings(): types.ProxySettings { + return { server: `socks5://127.0.0.1:${this._socksProxy.port()}` }; } public async close() { diff --git a/packages/playwright-core/src/server/transport.ts b/packages/playwright-core/src/server/transport.ts index e95ec8eaee429..30e87ae0c2eca 100644 --- a/packages/playwright-core/src/server/transport.ts +++ b/packages/playwright-core/src/server/transport.ts @@ -84,12 +84,8 @@ export class WebSocketTransport implements ConnectionTransport { const logUrl = stripQueryParams(url); progress?.log(` ${logUrl}`); const transport = new WebSocketTransport(progress, url, logUrl, { ...options, followRedirects: !!options.followRedirects && hadRedirects }); - let success = false; - progress?.cleanupWhenAborted(async () => { - if (!success) - await transport.closeAndWait().catch(e => null); - }); - const result = await new Promise<{ transport?: WebSocketTransport, redirect?: IncomingMessage }>((fulfill, reject) => { + progress?.cleanupWhenAborted(() => transport.closeAndWait()); + const resultPromise = new Promise<{ transport?: WebSocketTransport, redirect?: IncomingMessage }>((fulfill, reject) => { transport._ws.on('open', async () => { progress?.log(` ${logUrl}`); fulfill({ transport }); @@ -120,6 +116,7 @@ export class WebSocketTransport implements ConnectionTransport { }); }); }); + const result = progress ? await progress.race(resultPromise) : await resultPromise; if (result.redirect) { // Strip authorization headers from the redirected request. @@ -128,8 +125,6 @@ export class WebSocketTransport implements ConnectionTransport { })); return WebSocketTransport._connect(progress, result.redirect.headers.location!, { ...options, headers: newHeaders }, true /* hadRedirects */); } - - success = true; return transport; } diff --git a/packages/playwright-core/src/server/utils/processLauncher.ts b/packages/playwright-core/src/server/utils/processLauncher.ts index 90bc1507fc74a..2f204674ec5db 100644 --- a/packages/playwright-core/src/server/utils/processLauncher.ts +++ b/packages/playwright-core/src/server/utils/processLauncher.ts @@ -164,7 +164,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise { failed(new Error('Failed to launch: ' + error)); }); - return cleanup().then(() => failedPromise).then(e => Promise.reject(e)); + return failedPromise.then(e => Promise.reject(e)); } options.log(` pid=${spawnedProcess.pid}`); diff --git a/tests/library/browser.spec.ts b/tests/library/browser.spec.ts index 17bdc142131d0..19d47f871a4ea 100644 --- a/tests/library/browser.spec.ts +++ b/tests/library/browser.spec.ts @@ -62,3 +62,11 @@ test('should dispatch page.on(close) upon browser.close and reject evaluate', as const error = await promise; expect(error.message).toContain(kTargetClosedErrorMessage); }); + +test('newContext should not leave a context upon failure', async ({ browser, toImpl }) => { + const error = await browser.newContext({ + __testHookBeforeSetStorageState: () => Promise.reject(new Error('Oh my')), + } as any).catch(e => e); + expect(error.message).toContain('Oh my'); + await expect.poll(() => toImpl(browser).contexts().length).toBe(0); +}); From c0da19366092bddc35dae1f5b5f2cd072079604d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 10:47:36 +0100 Subject: [PATCH 52/71] chore: make various progress instances "strict" (#36349) --- .../src/server/android/android.ts | 62 ++++++++++--------- .../src/server/browserContext.ts | 32 +++++----- .../src/server/chromium/videoRecorder.ts | 3 +- .../server/dispatchers/androidDispatcher.ts | 8 +-- packages/playwright-core/src/server/frames.ts | 22 +++---- packages/playwright-core/src/server/page.ts | 54 ++++++++-------- .../src/server/recorder/recorderApp.ts | 2 +- .../src/server/trace/viewer/traceViewer.ts | 2 +- 8 files changed, 97 insertions(+), 88 deletions(-) diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index b8a4c4772f428..3b7e4cebf34e3 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -32,9 +32,9 @@ import { chromiumSwitches } from '../chromium/chromiumSwitches'; import { CRBrowser } from '../chromium/crBrowser'; import { removeFolders } from '../utils/fileUtils'; import { helper } from '../helper'; -import { SdkObject, serverSideCallMetadata } from '../instrumentation'; +import { CallMetadata, SdkObject } from '../instrumentation'; import { gracefullyCloseSet } from '../utils/processLauncher'; -import { ProgressController } from '../progress'; +import { Progress, ProgressController } from '../progress'; import { registry } from '../registry'; import type { BrowserOptions, BrowserProcess } from '../browser'; @@ -122,6 +122,7 @@ export class AndroidDevice extends SdkObject { this.model = model; this.serial = backend.serial; this._options = options; + this.logName = 'browser'; } static async create(android: Android, backend: DeviceBackend, options: channels.AndroidDevicesOptions): Promise { @@ -258,18 +259,21 @@ export class AndroidDevice extends SdkObject { this.emit(AndroidDevice.Events.Close); } - async launchBrowser(pkg: string = 'com.android.chrome', options: channels.AndroidDeviceLaunchBrowserParams): Promise { - debug('pw:android')('Force-stopping', pkg); - await this._backend.runCommand(`shell:am force-stop ${pkg}`); - const socketName = isUnderTest() ? 'webview_devtools_remote_playwright_test' : ('playwright_' + createGuid() + '_devtools_remote'); - const commandLine = this._defaultArgs(options, socketName).join(' '); - debug('pw:android')('Starting', pkg, commandLine); - // encode commandLine to base64 to avoid issues (bash encoding) with special characters - await this._backend.runCommand(`shell:echo "${Buffer.from(commandLine).toString('base64')}" | base64 -d > /data/local/tmp/chrome-command-line`); - await this._backend.runCommand(`shell:am start -a android.intent.action.VIEW -d about:blank ${pkg}`); - const browserContext = await this._connectToBrowser(socketName, options); - await this._backend.runCommand(`shell:rm /data/local/tmp/chrome-command-line`); - return browserContext; + async launchBrowser(metadata: CallMetadata, pkg: string = 'com.android.chrome', options: channels.AndroidDeviceLaunchBrowserParams): Promise { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(async progress => { + debug('pw:android')('Force-stopping', pkg); + await this._backend.runCommand(`shell:am force-stop ${pkg}`); + const socketName = isUnderTest() ? 'webview_devtools_remote_playwright_test' : ('playwright_' + createGuid() + '_devtools_remote'); + const commandLine = this._defaultArgs(options, socketName).join(' '); + debug('pw:android')('Starting', pkg, commandLine); + // encode commandLine to base64 to avoid issues (bash encoding) with special characters + await progress.race(this._backend.runCommand(`shell:echo "${Buffer.from(commandLine).toString('base64')}" | base64 -d > /data/local/tmp/chrome-command-line`)); + await progress.race(this._backend.runCommand(`shell:am start -a android.intent.action.VIEW -d about:blank ${pkg}`)); + const browserContext = await this._connectToBrowser(progress, socketName, options); + await progress.race(this._backend.runCommand(`shell:rm /data/local/tmp/chrome-command-line`)); + return browserContext; + }); } private _defaultArgs(options: channels.AndroidDeviceLaunchBrowserParams, socketName: string): string[] { @@ -301,25 +305,30 @@ export class AndroidDevice extends SdkObject { return chromeArguments; } - async connectToWebView(socketName: string): Promise { - const webView = this._webViews.get(socketName); - if (!webView) - throw new Error('WebView has been closed'); - return await this._connectToBrowser(socketName); + async connectToWebView(metadata: CallMetadata, socketName: string): Promise { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(async progress => { + const webView = this._webViews.get(socketName); + if (!webView) + throw new Error('WebView has been closed'); + return await this._connectToBrowser(progress, socketName); + }); } - private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise { - const socket = await this._waitForLocalAbstract(socketName); + private async _connectToBrowser(progress: Progress, socketName: string, options: types.BrowserContextOptions = {}): Promise { + const socket = await progress.race(this._waitForLocalAbstract(socketName)); const androidBrowser = new AndroidBrowser(this, socket); - await androidBrowser._init(); + progress.cleanupWhenAborted(() => androidBrowser.close()); + await progress.race(androidBrowser._init()); this._browserConnections.add(androidBrowser); - const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); + const artifactsDir = await progress.race(fs.promises.mkdtemp(ARTIFACTS_FOLDER)); const cleanupArtifactsDir = async () => { const errors = (await removeFolders([artifactsDir])).filter(Boolean); for (let i = 0; i < (errors || []).length; ++i) debug('pw:android')(`exception while removing ${artifactsDir}: ${errors[i]}`); }; + progress.cleanupWhenAborted(cleanupArtifactsDir); gracefullyCloseSet.add(cleanupArtifactsDir); socket.on('close', async () => { gracefullyCloseSet.delete(cleanupArtifactsDir); @@ -341,12 +350,9 @@ export class AndroidDevice extends SdkObject { }; validateBrowserContextOptions(options, browserOptions); - const browser = await CRBrowser.connect(this.attribution.playwright, androidBrowser, browserOptions); - const controller = new ProgressController(serverSideCallMetadata(), this); + const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, androidBrowser, browserOptions)); const defaultContext = browser._defaultContext!; - await controller.run(async progress => { - await defaultContext._loadDefaultContextAsIs(progress); - }); + await defaultContext._loadDefaultContextAsIs(progress); return defaultContext; } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 0a5b7eac3aab5..bd7e934e7ddc2 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -191,12 +191,12 @@ export abstract class BrowserContext extends SdkObject { } async resetForReuse(metadata: CallMetadata, params: channels.BrowserNewContextForReuseParams | null) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(progress => this.resetForReuseImpl(progress, params)); } async resetForReuseImpl(progress: Progress, params: channels.BrowserNewContextForReuseParams | null) { - await this.tracing.resetForReuse(); + await progress.race(this.tracing.resetForReuse()); if (params) { for (const key of paramsThatAllowContextReuse) @@ -219,18 +219,22 @@ export abstract class BrowserContext extends SdkObject { await page?.mainFrame().gotoImpl(progress, 'about:blank', {}); await this._resetStorage(progress); - await this.clock.resetForReuse(); - // TODO: following can be optimized to not perform noops. - if (this._options.permissions) - await this.grantPermissions(this._options.permissions); - else - await this.clearPermissions(); - await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || []); - await this.setGeolocation(this._options.geolocation); - await this.setOffline(!!this._options.offline); - await this.setUserAgent(this._options.userAgent); - await this.clearCache(); - await this._resetCookies(); + + const resetOptions = async () => { + await this.clock.resetForReuse(); + // TODO: following can be optimized to not perform noops. + if (this._options.permissions) + await this.grantPermissions(this._options.permissions); + else + await this.clearPermissions(); + await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || []); + await this.setGeolocation(this._options.geolocation); + await this.setOffline(!!this._options.offline); + await this.setUserAgent(this._options.userAgent); + await this.clearCache(); + await this._resetCookies(); + }; + await progress.race(resetOptions()); await page?.resetForReuse(progress); } diff --git a/packages/playwright-core/src/server/chromium/videoRecorder.ts b/packages/playwright-core/src/server/chromium/videoRecorder.ts index 160fd354812b6..5b15cf5a20344 100644 --- a/packages/playwright-core/src/server/chromium/videoRecorder.ts +++ b/packages/playwright-core/src/server/chromium/videoRecorder.ts @@ -42,10 +42,11 @@ export class VideoRecorder { if (!options.outputFile.endsWith('.webm')) throw new Error('File must have .webm extension'); - const controller = new ProgressController(serverSideCallMetadata(), page); + const controller = new ProgressController(serverSideCallMetadata(), page, 'strict'); controller.setLogName('browser'); return await controller.run(async progress => { const recorder = new VideoRecorder(page, ffmpegPath, progress); + progress.cleanupWhenAborted(() => recorder.stop()); await recorder._launch(options); return recorder; }); diff --git a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts index ea5d31461c961..6972d50f2d21c 100644 --- a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts @@ -160,10 +160,10 @@ export class AndroidDeviceDispatcher extends Dispatcher { + async launchBrowser(params: channels.AndroidDeviceLaunchBrowserParams, metadata: CallMetadata): Promise { if (this.parentScope()._denyLaunch) throw new Error(`Launching more browsers is not allowed.`); - const context = await this._object.launchBrowser(params.pkg, params); + const context = await this._object.launchBrowser(metadata, params.pkg, params); return { context: BrowserContextDispatcher.from(this, context) }; } @@ -171,10 +171,10 @@ export class AndroidDeviceDispatcher extends Dispatcher { + async connectToWebView(params: channels.AndroidDeviceConnectToWebViewParams, metadata: CallMetadata): Promise { if (this.parentScope()._denyLaunch) throw new Error(`Launching more browsers is not allowed.`); - return { context: BrowserContextDispatcher.from(this, await this._object.connectToWebView(params.socketName)) }; + return { context: BrowserContextDispatcher.from(this, await this._object.connectToWebView(metadata, params.socketName)) }; } } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 5ce23ffb5cbe0..a0bc51b7f37c4 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1370,9 +1370,9 @@ export class Frame extends SdkObject { } async ariaSnapshot(metadata: CallMetadata, selector: string, options: { forAI?: boolean } & types.TimeoutOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => handle.ariaSnapshot(options)); + return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => progress.race(handle.ariaSnapshot(options))); }, options.timeout); } @@ -1391,7 +1391,7 @@ export class Frame extends SdkObject { const start = timeout > 0 ? monotonicTime() : 0; // Step 1: perform locator handlers checkpoint with a specified timeout. - await (new ProgressController(metadata, this)).run(async progress => { + await (new ProgressController(metadata, this, 'strict')).run(async progress => { progress.log(`${renderTitleForCall(metadata)}${timeout ? ` with timeout ${timeout}ms` : ''}`); if (selector) progress.log(`waiting for ${this._asLocator(selector)}`); @@ -1402,7 +1402,7 @@ export class Frame extends SdkObject { // Supports the case of `expect(locator).toBeVisible({ timeout: 1 })` // that should succeed when the locator is already visible. try { - const resultOneShot = await (new ProgressController(metadata, this)).run(async progress => { + const resultOneShot = await (new ProgressController(metadata, this, 'strict')).run(async progress => { return await this._expectInternal(progress, selector, options, lastIntermediateResult); }); if (resultOneShot.matches !== options.isNot) @@ -1420,7 +1420,7 @@ export class Frame extends SdkObject { return { matches: options.isNot, log: compressCallLog(metadata.log), timedOut: true, received: lastIntermediateResult.received }; // Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time. - return await (new ProgressController(metadata, this)).run(async progress => { + return await (new ProgressController(metadata, this, 'strict')).run(async progress => { return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { await this._page.performActionPreChecks(progress); const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult); @@ -1448,16 +1448,14 @@ export class Frame extends SdkObject { } private async _expectInternal(progress: Progress, selector: string | undefined, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) { - const selectorInFrame = selector ? await this.selectors.resolveFrameForSelector(selector, { strict: true }) : undefined; - progress.throwIfAborted(); + const selectorInFrame = selector ? await progress.race(this.selectors.resolveFrameForSelector(selector, { strict: true })) : undefined; const { frame, info } = selectorInFrame || { frame: this, info: undefined }; const world = options.expression === 'to.have.property' ? 'main' : (info?.world ?? 'utility'); - const context = await frame._context(world); - const injected = await context.injectedScript(); - progress.throwIfAborted(); + const context = await progress.race(frame._context(world)); + const injected = await progress.race(context.injectedScript()); - const { log, matches, received, missingReceived } = await injected.evaluate(async (injected, { info, options, callId }) => { + const { log, matches, received, missingReceived } = await progress.race(injected.evaluate(async (injected, { info, options, callId }) => { const elements = info ? injected.querySelectorAll(info.parsed, document) : []; if (callId) injected.markTargetElements(new Set(elements), callId); @@ -1470,7 +1468,7 @@ export class Frame extends SdkObject { else if (elements.length) log = ` locator resolved to ${injected.previewNode(elements[0])}`; return { log, ...await injected.expect(elements[0], options, elements) }; - }, { info, options, callId: progress.metadata.id }); + }, { info, options, callId: progress.metadata.id })); if (log) progress.log(log); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 162a2b6d91dda..9a9bf878a1ab1 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -812,10 +812,13 @@ export class Page extends SdkObject { this._isServerSideOnly = true; } - async snapshotForAI(metadata: CallMetadata): Promise { - this.lastSnapshotFrameIds = []; - const snapshot = await snapshotFrameForAI(metadata, this.mainFrame(), 0, this.lastSnapshotFrameIds); - return snapshot.join('\n'); + snapshotForAI(metadata: CallMetadata): Promise { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(async progress => { + this.lastSnapshotFrameIds = []; + const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), 0, this.lastSnapshotFrameIds); + return snapshot.join('\n'); + }); } } @@ -991,29 +994,26 @@ class FrameThrottler { } } -async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, frameOrdinal: number, frameIds: string[]): Promise { +async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frameOrdinal: number, frameIds: string[]): Promise { // Only await the topmost navigations, inner frames will be empty when racing. - const controller = new ProgressController(metadata, frame); - const snapshot = await controller.run(progress => { - return frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => { - try { - const context = await frame._utilityContext(); - const injectedScript = await context.injectedScript(); - const snapshotOrRetry = await injectedScript.evaluate((injected, refPrefix) => { - const node = injected.document.body; - if (!node) - return true; - return injected.ariaSnapshot(node, { forAI: true, refPrefix }); - }, frameOrdinal ? 'f' + frameOrdinal : ''); - if (snapshotOrRetry === true) - return continuePolling; - return snapshotOrRetry; - } catch (e) { - if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e)) - throw e; + const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => { + try { + const context = await progress.race(frame._utilityContext()); + const injectedScript = await progress.race(context.injectedScript()); + const snapshotOrRetry = await progress.race(injectedScript.evaluate((injected, refPrefix) => { + const node = injected.document.body; + if (!node) + return true; + return injected.ariaSnapshot(node, { forAI: true, refPrefix }); + }, frameOrdinal ? 'f' + frameOrdinal : '')); + if (snapshotOrRetry === true) return continuePolling; - } - }); + return snapshotOrRetry; + } catch (e) { + if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e)) + throw e; + return continuePolling; + } }); const lines = snapshot.split('\n'); @@ -1029,7 +1029,7 @@ async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, f const ref = match[2]; const frameSelector = `aria-ref=${ref} >> internal:control=enter-frame`; const frameBodySelector = `${frameSelector} >> body`; - const child = await frame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true }); + const child = await progress.race(frame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true })); if (!child) { result.push(line); continue; @@ -1037,7 +1037,7 @@ async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, f const frameOrdinal = frameIds.length + 1; frameIds.push(child.frame._id); try { - const childSnapshot = await snapshotFrameForAI(metadata, child.frame, frameOrdinal, frameIds); + const childSnapshot = await snapshotFrameForAI(progress, child.frame, frameOrdinal, frameIds); result.push(line + ':', ...childSnapshot.map(l => leadingSpace + ' ' + l)); } catch { result.push(line); diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 5f69e225f2cfc..f9aea392e86da 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -121,7 +121,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { timeout: 0, } }); - const controller = new ProgressController(serverSideCallMetadata(), context._browser); + const controller = new ProgressController(serverSideCallMetadata(), context._browser, 'strict'); await controller.run(async progress => { await context._browser._defaultContext!._loadDefaultContextAsIs(progress); }); diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 68a5fdd5c76b7..c202d65e6fc38 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -180,7 +180,7 @@ export async function openTraceViewerApp(url: string, browserName: string, optio }, }); - const controller = new ProgressController(serverSideCallMetadata(), context._browser); + const controller = new ProgressController(serverSideCallMetadata(), context._browser, 'strict'); await controller.run(async progress => { await context._browser._defaultContext!._loadDefaultContextAsIs(progress); }); From d3970a22d1df8ff2c96e0cb101d88738f3d63d70 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 13:04:41 +0200 Subject: [PATCH 53/71] chore: smaller codex fixes (#36374) --- packages/playwright/src/matchers/toMatchSnapshot.ts | 2 +- packages/playwright/src/runner/testServer.ts | 2 +- packages/playwright/src/util.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 98fc72a6f2f85..b2ef3c332326e 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -135,7 +135,7 @@ class SnapshotHelper { this.locator = locator; this.updateSnapshots = testInfo.config.updateSnapshots; - this.mimeType = mime.getType(path.basename(this.expectedPath)) ?? 'application/octet-string'; + this.mimeType = mime.getType(path.basename(this.expectedPath)) ?? 'application/octet-stream'; this.comparator = getComparator(this.mimeType); this.testInfo = testInfo; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 61be3b2929960..4a182fbdc72ee 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -142,7 +142,7 @@ export class TestServerDispatcher implements TestServerInterface { process.stdout.columns = params.cols; process.stdout.rows = params.rows; process.stderr.columns = params.cols; - process.stderr.columns = params.rows; + process.stderr.rows = params.rows; } async checkBrowsers(): Promise<{ hasBrowsers: boolean; }> { diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 00a839cb19eb8..a7d120770ff44 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -147,7 +147,7 @@ export function createTitleMatcher(patterns: RegExp | RegExp[]): Matcher { }; } -export function mergeObjects(a: A | undefined | void, b: B | undefined | void, c: B | undefined | void): A & B & C { +export function mergeObjects(a: A | undefined | void, b: B | undefined | void, c: C | undefined | void): A & B & C { const result = { ...a } as any; for (const x of [b, c].filter(Boolean)) { for (const [name, value] of Object.entries(x as any)) { From 71088f6e9dce156ceecb3e381368942f9b6b0a8e Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 20 Jun 2025 13:40:11 +0200 Subject: [PATCH 54/71] chore: refactor browser creation from PlaywrightConnection into PlaywrightServer (#36369) Signed-off-by: Simon Knott Co-authored-by: Dmitry Gozman --- .../src/remote/playwrightConnection.ts | 249 +++--------------- .../src/remote/playwrightServer.ts | 227 ++++++++++++++-- .../dispatchers/playwrightDispatcher.ts | 2 +- 3 files changed, 233 insertions(+), 245 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 1a3dcb2a32327..d92caeee78835 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -14,63 +14,35 @@ * limitations under the License. */ -import { SocksProxy } from '../server/utils/socksProxy'; import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher } from '../server'; import { AndroidDevice } from '../server/android/android'; import { Browser } from '../server/browser'; import { DebugControllerDispatcher } from '../server/dispatchers/debugControllerDispatcher'; -import { serverSideCallMetadata } from '../server/instrumentation'; -import { assert } from '../utils/isomorphic/assert'; -import { isUnderTest } from '../server/utils/debug'; import { startProfiling, stopProfiling } from '../server/utils/profiler'; -import { monotonicTime } from '../utils'; +import { monotonicTime, Semaphore } from '../utils'; import { debugLogger } from '../server/utils/debugLogger'; +import { PlaywrightDispatcherOptions } from '../server/dispatchers/playwrightDispatcher'; import type { DispatcherScope, Playwright } from '../server'; -import type { LaunchOptions } from '../server/types'; import type { WebSocket } from '../utilsBundle'; -import type * as channels from '@protocol/channels'; - -export type ClientType = 'controller' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser-or-android'; - -type Options = { - allowFSPaths: boolean, - socksProxyPattern: string | undefined, - browserName: string | null, - launchOptions: LaunchOptions, - sharedBrowser?: boolean, -}; - -type PreLaunched = { - browser?: Browser | undefined; - androidDevice?: AndroidDevice | undefined; - socksProxy?: SocksProxy | undefined; -}; export class PlaywrightConnection { private _ws: WebSocket; - private _onClose: () => void; + private _semaphore: Semaphore; private _dispatcherConnection: DispatcherConnection; private _cleanups: (() => Promise)[] = []; private _id: string; private _disconnected = false; - private _playwright: Playwright; - private _preLaunched: PreLaunched; - private _options: Options; private _root: DispatcherScope; private _profileName: string; - constructor(lock: Promise, clientType: ClientType, ws: WebSocket, options: Options, playwright: Playwright, preLaunched: PreLaunched, id: string, onClose: () => void) { + constructor(semaphore: Semaphore, ws: WebSocket, controller: boolean, playwright: Playwright, initialize: () => Promise }>, id: string) { this._ws = ws; - this._playwright = playwright; - this._preLaunched = preLaunched; - this._options = options; - options.launchOptions = filterLaunchOptions(options.launchOptions, options.allowFSPaths); - if (clientType === 'pre-launched-browser-or-android') - assert(preLaunched.browser || preLaunched.androidDevice); - this._onClose = onClose; + this._semaphore = semaphore; this._id = id; - this._profileName = `${new Date().toISOString()}-${clientType}`; + this._profileName = new Date().toISOString(); + + const lock = this._semaphore.acquire(); this._dispatcherConnection = new DispatcherConnection(); this._dispatcherConnection.onmessage = async message => { @@ -98,148 +70,39 @@ export class PlaywrightConnection { ws.on('close', () => this._onDisconnect()); ws.on('error', (error: Error) => this._onDisconnect(error)); - if (clientType === 'controller') { - this._root = this._initDebugControllerMode(); + if (controller) { + debugLogger.log('server', `[${this._id}] engaged reuse controller mode`); + this._root = new DebugControllerDispatcher(this._dispatcherConnection, playwright.debugController); return; } - this._root = new RootDispatcher(this._dispatcherConnection, async (scope, options) => { + this._root = new RootDispatcher(this._dispatcherConnection, async (scope, params) => { await startProfiling(); - if (clientType === 'reuse-browser') - return await this._initReuseBrowsersMode(scope, options); - if (clientType === 'pre-launched-browser-or-android') - return this._preLaunched.browser ? await this._initPreLaunchedBrowserMode(scope, options) : await this._initPreLaunchedAndroidMode(scope); - if (clientType === 'launch-browser') - return await this._initLaunchBrowserMode(scope, options); - throw new Error('Unsupported client type: ' + clientType); - }); - } - - private async _initLaunchBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { - debugLogger.log('server', `[${this._id}] engaged launch mode for "${this._options.browserName}"`); - const ownedSocksProxy = await this._createOwnedSocksProxy(); - const browser = await this._playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); - browser.options.sdkLanguage = options.sdkLanguage; - - this._cleanups.push(() => browser.close({ reason: 'Connection terminated' })); - browser.on(Browser.Events.Disconnected, () => { - // Underlying browser did close for some reason - force disconnect the client. - this.close({ code: 1001, reason: 'Browser closed' }); - }); - - return new PlaywrightDispatcher(scope, this._playwright, { socksProxy: ownedSocksProxy, preLaunchedBrowser: browser, denyLaunch: true, }); - } - - private async _initPreLaunchedBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { - debugLogger.log('server', `[${this._id}] engaged pre-launched (browser) mode`); - // Note: connected client owns the socks proxy and configures the pattern. - this._preLaunched.socksProxy?.setPattern(this._options.socksProxyPattern); - - const browser = this._preLaunched.browser!; - browser.options.sdkLanguage = options.sdkLanguage; - browser.on(Browser.Events.Disconnected, () => { - // Underlying browser did close for some reason - force disconnect the client. - this.close({ code: 1001, reason: 'Browser closed' }); - }); - - const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { - socksProxy: this._preLaunched.socksProxy, - preLaunchedBrowser: browser, - sharedBrowser: this._options.sharedBrowser, - denyLaunch: true, - }); - // In pre-launched mode, keep only the pre-launched browser. - for (const b of this._playwright.allBrowsers()) { - if (b !== browser) - await b.close({ reason: 'Connection terminated' }); - } - this._cleanups.push(() => playwrightDispatcher.cleanup()); - return playwrightDispatcher; - } - - private async _initPreLaunchedAndroidMode(scope: RootDispatcher) { - debugLogger.log('server', `[${this._id}] engaged pre-launched (Android) mode`); - const androidDevice = this._preLaunched.androidDevice!; - androidDevice.on(AndroidDevice.Events.Close, () => { - // Underlying browser did close for some reason - force disconnect the client. - this.close({ code: 1001, reason: 'Android device disconnected' }); - }); - const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { preLaunchedAndroidDevice: androidDevice, denyLaunch: true }); - this._cleanups.push(() => playwrightDispatcher.cleanup()); - return playwrightDispatcher; - } - - private _initDebugControllerMode(): DebugControllerDispatcher { - debugLogger.log('server', `[${this._id}] engaged reuse controller mode`); - // Always create new instance based on the reused Playwright instance. - return new DebugControllerDispatcher(this._dispatcherConnection, this._playwright.debugController); - } - - private async _initReuseBrowsersMode(scope: RootDispatcher, options: channels.RootInitializeParams) { - // Note: reuse browser mode does not support socks proxy, because - // clients come and go, while the browser stays the same. - - debugLogger.log('server', `[${this._id}] engaged reuse browsers mode for ${this._options.browserName}`); - - const requestedOptions = launchOptionsHash(this._options.launchOptions); - let browser = this._playwright.allBrowsers().find(b => { - if (b.options.name !== this._options.browserName) - return false; - const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); - return existingOptions === requestedOptions; - }); - - // Close remaining browsers of this type+channel. Keep different browser types for the speed. - for (const b of this._playwright.allBrowsers()) { - if (b === browser) - continue; - if (b.options.name === this._options.browserName && b.options.channel === this._options.launchOptions.channel) - await b.close({ reason: 'Connection terminated' }); - } - - if (!browser) { - browser = await this._playwright[(this._options.browserName || 'chromium') as 'chromium'].launch(serverSideCallMetadata(), { - ...this._options.launchOptions, - headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS, - }); - browser.on(Browser.Events.Disconnected, () => { - // Underlying browser did close for some reason - force disconnect the client. - this.close({ code: 1001, reason: 'Browser closed' }); - }); - } - browser.options.sdkLanguage = options.sdkLanguage; - - this._cleanups.push(async () => { - // Don't close the pages so that user could debug them, - // but close all the empty browsers and contexts to clean up. - for (const browser of this._playwright.allBrowsers()) { - for (const context of browser.contexts()) { - if (!context.pages().length) - await context.close({ reason: 'Connection terminated' }); - else - await context.stopPendingOperations('Connection closed'); - } - if (!browser.contexts()) - await browser.close({ reason: 'Connection terminated' }); + const options = await initialize(); + if (options.preLaunchedBrowser) { + const browser = options.preLaunchedBrowser; + browser.options.sdkLanguage = params.sdkLanguage; + browser.on(Browser.Events.Disconnected, () => { + // Underlying browser did close for some reason - force disconnect the client. + this.close({ code: 1001, reason: 'Browser closed' }); + }); } - }); + if (options.preLaunchedAndroidDevice) { + const androidDevice = options.preLaunchedAndroidDevice; + androidDevice.on(AndroidDevice.Events.Close, () => { + // Underlying android device did close for some reason - force disconnect the client. + this.close({ code: 1001, reason: 'Android device disconnected' }); + }); + } + if (options.dispose) + this._cleanups.push(options.dispose); - const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { preLaunchedBrowser: browser, denyLaunch: true }); - return playwrightDispatcher; - } + const dispatcher = new PlaywrightDispatcher(scope, playwright, options); + this._cleanups.push(() => dispatcher.cleanup()); - private async _createOwnedSocksProxy(): Promise { - if (!this._options.socksProxyPattern) { - this._options.launchOptions.socksProxyPort = undefined; - return; - } - const socksProxy = new SocksProxy(); - socksProxy.setPattern(this._options.socksProxyPattern); - this._options.launchOptions.socksProxyPort = await socksProxy.listen(0); - debugLogger.log('server', `[${this._id}] started socks proxy on port ${this._options.launchOptions.socksProxyPort}`); - this._cleanups.push(() => socksProxy.close()); - return socksProxy; + return dispatcher; + }); } private async _onDisconnect(error?: Error) { @@ -250,7 +113,7 @@ export class PlaywrightConnection { for (const cleanup of this._cleanups) await cleanup().catch(() => {}); await stopProfiling(this._profileName); - this._onClose(); + this._semaphore.release(); debugLogger.log('server', `[${this._id}] finished cleanup`); } @@ -275,47 +138,3 @@ export class PlaywrightConnection { } } } - -function launchOptionsHash(options: LaunchOptions) { - const copy = { ...options }; - for (const k of Object.keys(copy)) { - const key = k as keyof LaunchOptions; - if (copy[key] === defaultLaunchOptions[key]) - delete copy[key]; - } - for (const key of optionsThatAllowBrowserReuse) - delete copy[key]; - return JSON.stringify(copy); -} - -function filterLaunchOptions(options: LaunchOptions, allowFSPaths: boolean): LaunchOptions { - return { - channel: options.channel, - args: options.args, - ignoreAllDefaultArgs: options.ignoreAllDefaultArgs, - ignoreDefaultArgs: options.ignoreDefaultArgs, - timeout: options.timeout, - headless: options.headless, - proxy: options.proxy, - chromiumSandbox: options.chromiumSandbox, - firefoxUserPrefs: options.firefoxUserPrefs, - slowMo: options.slowMo, - executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined, - downloadsPath: allowFSPaths ? options.downloadsPath : undefined, - }; -} - -const defaultLaunchOptions: Partial = { - ignoreAllDefaultArgs: false, - handleSIGINT: false, - handleSIGTERM: false, - handleSIGHUP: false, - headless: true, - devtools: false, -}; - -const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [ - 'headless', - 'timeout', - 'tracesDir', -]; diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index a6add53eb4e14..ced787c64bc40 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -21,9 +21,10 @@ import { DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT } from '../utils/isomorphic/time'; import { WSServer } from '../server/utils/wsServer'; import { wrapInASCIIBox } from '../server/utils/ascii'; import { getPlaywrightVersion } from '../server/utils/userAgent'; +import { debugLogger, isUnderTest } from '../utils'; +import { serverSideCallMetadata } from '../server'; +import { SocksProxy } from '../server/utils/socksProxy'; -import type { ClientType } from './playwrightConnection'; -import type { SocksProxy } from '../server/utils/socksProxy'; import type { AndroidDevice } from '../server/android/android'; import type { Browser } from '../server/browser'; import type { Playwright } from '../server/playwright'; @@ -94,42 +95,166 @@ export class PlaywrightServer { } catch (e) { } - // Instantiate playwright for the extension modes. const isExtension = this._options.mode === 'extension'; - let clientType: ClientType = 'launch-browser'; - let semaphore: Semaphore = browserSemaphore; - if (isExtension && url.searchParams.has('debug-controller')) { - clientType = 'controller'; - semaphore = controllerSemaphore; - } else if (isExtension) { - clientType = 'reuse-browser'; - semaphore = reuseBrowserSemaphore; - } else if (this._options.mode === 'launchServer' || this._options.mode === 'launchServerShared') { - clientType = 'pre-launched-browser-or-android'; - semaphore = browserSemaphore; + const allowFSPaths = isExtension; + launchOptions = filterLaunchOptions(launchOptions, allowFSPaths); + + if (isExtension) { + if (url.searchParams.has('debug-controller')) { + return new PlaywrightConnection( + controllerSemaphore, + ws, + true, + this._playwright, + async () => { throw new Error('shouldnt be used'); }, + id, + ); + } + return new PlaywrightConnection( + reuseBrowserSemaphore, + ws, + false, + this._playwright, + () => this._initReuseBrowsersMode(browserName, launchOptions, id), + id, + ); + } + + if (this._options.mode === 'launchServer' || this._options.mode === 'launchServerShared') { + if (this._options.preLaunchedBrowser) { + return new PlaywrightConnection( + browserSemaphore, + ws, + false, + this._playwright, + () => this._initPreLaunchedBrowserMode(id), + id, + ); + } + + return new PlaywrightConnection( + browserSemaphore, + ws, + false, + this._playwright, + () => this._initPreLaunchedAndroidMode(id), + id, + ); } return new PlaywrightConnection( - semaphore.acquire(), - clientType, ws, - { - socksProxyPattern: proxyValue, - browserName, - launchOptions, - allowFSPaths: this._options.mode === 'extension', - sharedBrowser: this._options.mode === 'launchServerShared', - }, + browserSemaphore, + ws, + false, this._playwright, - { - browser: this._options.preLaunchedBrowser, - androidDevice: this._options.preLaunchedAndroidDevice, - socksProxy: this._options.preLaunchedSocksProxy, - }, - id, () => semaphore.release()); + () => this._initLaunchBrowserMode(browserName, proxyValue, launchOptions, id), + id, + ); }, }); } + private async _initReuseBrowsersMode(browserName: string | null, launchOptions: LaunchOptions, id: string) { + // Note: reuse browser mode does not support socks proxy, because + // clients come and go, while the browser stays the same. + + debugLogger.log('server', `[${id}] engaged reuse browsers mode for ${browserName}`); + + const requestedOptions = launchOptionsHash(launchOptions); + let browser = this._playwright.allBrowsers().find(b => { + if (b.options.name !== browserName) + return false; + const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); + return existingOptions === requestedOptions; + }); + + // Close remaining browsers of this type+channel. Keep different browser types for the speed. + for (const b of this._playwright.allBrowsers()) { + if (b === browser) + continue; + if (b.options.name === browserName && b.options.channel === launchOptions.channel) + await b.close({ reason: 'Connection terminated' }); + } + + if (!browser) { + browser = await this._playwright[(browserName || 'chromium') as 'chromium'].launch(serverSideCallMetadata(), { + ...launchOptions, + headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS, + }); + } + + return { + preLaunchedBrowser: browser, + denyLaunch: true, + dispose: async () => { + // Don't close the pages so that user could debug them, + // but close all the empty browsers and contexts to clean up. + for (const browser of this._playwright.allBrowsers()) { + for (const context of browser.contexts()) { + if (!context.pages().length) + await context.close({ reason: 'Connection terminated' }); + else + await context.stopPendingOperations('Connection closed'); + } + if (!browser.contexts()) + await browser.close({ reason: 'Connection terminated' }); + } + } + }; + } + + private async _initPreLaunchedBrowserMode(id: string) { + debugLogger.log('server', `[${id}] engaged pre-launched (browser) mode`); + + const browser = this._options.preLaunchedBrowser!; + + // In pre-launched mode, keep only the pre-launched browser. + for (const b of this._playwright.allBrowsers()) { + if (b !== browser) + await b.close({ reason: 'Connection terminated' }); + } + + return { + preLaunchedBrowser: browser, + socksProxy: this._options.preLaunchedSocksProxy, + sharedBrowser: this._options.mode === 'launchServerShared', + denyLaunch: true, + }; + } + + private async _initPreLaunchedAndroidMode(id: string) { + debugLogger.log('server', `[${id}] engaged pre-launched (Android) mode`); + const androidDevice = this._options.preLaunchedAndroidDevice!; + return { + preLaunchedAndroidDevice: androidDevice, + denyLaunch: true, + }; + } + + private async _initLaunchBrowserMode(browserName: string | null, proxyValue: string | undefined, launchOptions: LaunchOptions, id: string) { + debugLogger.log('server', `[${id}] engaged launch mode for "${browserName}"`); + let socksProxy: SocksProxy | undefined; + if (proxyValue) { + socksProxy = new SocksProxy(); + socksProxy.setPattern(proxyValue); + launchOptions.socksProxyPort = await socksProxy.listen(0); + debugLogger.log('server', `[${id}] started socks proxy on port ${launchOptions.socksProxyPort}`); + } else { + launchOptions.socksProxyPort = undefined; + } + const browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), launchOptions); + return { + preLaunchedBrowser: browser, + socksProxy, + sharedBrowser: true, + denyLaunch: true, + dispose: async () => { + await browser.close({ reason: 'Connection terminated' }); + socksProxy?.close(); + }, + }; + } + async listen(port: number = 0, hostname?: string): Promise { return this._wsServer.listen(port, hostname, this._options.path); } @@ -163,3 +288,47 @@ function userAgentVersionMatchesErrorMessage(userAgent: string) { ].join('\n'), 1); } } + +function launchOptionsHash(options: LaunchOptions) { + const copy = { ...options }; + for (const k of Object.keys(copy)) { + const key = k as keyof LaunchOptions; + if (copy[key] === defaultLaunchOptions[key]) + delete copy[key]; + } + for (const key of optionsThatAllowBrowserReuse) + delete copy[key]; + return JSON.stringify(copy); +} + +function filterLaunchOptions(options: LaunchOptions, allowFSPaths: boolean): LaunchOptions { + return { + channel: options.channel, + args: options.args, + ignoreAllDefaultArgs: options.ignoreAllDefaultArgs, + ignoreDefaultArgs: options.ignoreDefaultArgs, + timeout: options.timeout, + headless: options.headless, + proxy: options.proxy, + chromiumSandbox: options.chromiumSandbox, + firefoxUserPrefs: options.firefoxUserPrefs, + slowMo: options.slowMo, + executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined, + downloadsPath: allowFSPaths ? options.downloadsPath : undefined, + }; +} + +const defaultLaunchOptions: Partial = { + ignoreAllDefaultArgs: false, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + headless: true, + devtools: false, +}; + +const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [ + 'headless', + 'timeout', + 'tracesDir', +]; diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 04bb144d1176a..a80aa49456299 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -35,7 +35,7 @@ import type { Browser } from '../browser'; import type { Playwright } from '../playwright'; import type * as channels from '@protocol/channels'; -type PlaywrightDispatcherOptions = { +export type PlaywrightDispatcherOptions = { socksProxy?: SocksProxy; denyLaunch?: boolean; preLaunchedBrowser?: Browser; From 73f840c1d014bbdf4d8d8da594e89430cdbcffa4 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 12:50:52 +0100 Subject: [PATCH 55/71] chore: use isNonRetriableError in more places (#36373) --- packages/playwright-core/src/server/dom.ts | 7 +++---- packages/playwright-core/src/server/frames.ts | 12 ++++++------ packages/playwright-core/src/server/page.ts | 9 ++++----- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index ce9ce887c03b3..35ed3cabd2bcf 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -17,10 +17,9 @@ import fs from 'fs'; import * as js from './javascript'; -import { isAbortError, ProgressController } from './progress'; +import { ProgressController } from './progress'; import { asLocator, isUnderTest } from '../utils'; import { prepareFilesForUpload } from './fileUploadUtils'; -import { isSessionClosedError } from './protocolError'; import * as rawInjectedScriptSource from '../generated/injectedScriptSource'; import type * as frames from './frames'; @@ -141,7 +140,7 @@ export class ElementHandle extends js.JSHandle { const utility = await this._frame._utilityContext(); return await utility.evaluate(pageFunction, [await utility.injectedScript(), this, arg]); } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (this._frame.isNonRetriableError(e)) throw e; return 'error:notconnected'; } @@ -152,7 +151,7 @@ export class ElementHandle extends js.JSHandle { const utility = await this._frame._utilityContext(); return await utility.evaluateHandle(pageFunction, [await utility.injectedScript(), this, arg]); } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (this._frame.isNonRetriableError(e)) throw e; return 'error:notconnected'; } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index a0bc51b7f37c4..9e32a7d8370a2 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -864,7 +864,7 @@ export class Frame extends SdkObject { return retVal; }); } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (this.isNonRetriableError(e)) throw e; throw new Error(`Unable to retrieve content because the page is navigating and changing the content.`); } @@ -1060,14 +1060,14 @@ export class Frame extends SdkObject { continue; return result as R; } catch (e) { - if (this._isErrorThatCannotBeRetried(e)) + if (this.isNonRetriableError(e)) throw e; continue; } } } - private _isErrorThatCannotBeRetried(e: Error) { + isNonRetriableError(e: Error) { if (isAbortError(e)) return true; // Always fail on JavaScript errors or when the main connection is closed. @@ -1288,7 +1288,7 @@ export class Frame extends SdkObject { return state.matches; }, { info: resolved.info, root: resolved.frame === this ? scope : undefined })); } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e)) + if (this.isNonRetriableError(e)) throw e; return false; } @@ -1408,7 +1408,7 @@ export class Frame extends SdkObject { if (resultOneShot.matches !== options.isNot) return resultOneShot; } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) + if (this.isNonRetriableError(e)) throw e; // Ignore any other errors from one-shot, we'll handle them during retries. } @@ -1434,7 +1434,7 @@ export class Frame extends SdkObject { }); }, timeout); } catch (e) { - // Q: Why not throw upon isSessionClosedError(e) as in other places? + // Q: Why not throw upon isNonRetriableError(e) as in other places? // A: We want user to receive a friendly message containing the last intermediate result. if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) throw e; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 9a9bf878a1ab1..42ad32e9c020e 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -25,7 +25,7 @@ import { helper } from './helper'; import * as input from './input'; import { SdkObject } from './instrumentation'; import * as js from './javascript'; -import { isAbortError, ProgressController } from './progress'; +import { ProgressController } from './progress'; import { Screenshotter, validateScreenshotOptions } from './screenshotter'; import { LongStandingScope, assert, renderTitleForCall, trimStringWithEllipsis } from '../utils'; import { asLocator } from '../utils'; @@ -36,7 +36,6 @@ import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { parseEvaluationResultValue } from '../utils/isomorphic/utilityScriptSerializers'; import { compressCallLog } from './callLog'; import * as rawBindingsControllerSource from '../generated/bindingsControllerSource'; -import { isSessionClosedError } from './protocolError'; import type { Artifact } from './artifact'; import type * as dom from './dom'; @@ -643,7 +642,7 @@ export class Page extends SdkObject { progress.log(`waiting ${screenshotTimeout}ms before taking screenshot`); previous = actual; actual = await rafrafScreenshot(progress, screenshotTimeout).catch(e => { - if (isAbortError(e)) + if (this.mainFrame().isNonRetriableError(e)) throw e; progress.log(`failed to take screenshot - ` + e.message); return undefined; @@ -676,7 +675,7 @@ export class Page extends SdkObject { } throw new Error(intermediateResult!.errorMessage); }, callTimeout).catch(e => { - // Q: Why not throw upon isSessionClosedError(e) as in other places? + // Q: Why not throw upon isNonRetriableError(e) as in other places? // A: We want user to receive a friendly diff between actual and expected/previous. if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) throw e; @@ -1010,7 +1009,7 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frame return continuePolling; return snapshotOrRetry; } catch (e) { - if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e)) + if (frame.isNonRetriableError(e)) throw e; return continuePolling; } From 556fea9444681220982cf7797cb98fdf33522d96 Mon Sep 17 00:00:00 2001 From: Kevin Tan Date: Fri, 20 Jun 2025 20:09:32 +0800 Subject: [PATCH 56/71] fix: adding trialing slash detection logic back in urlToWSEndpoint (#36357) --- .../src/server/chromium/chromium.ts | 2 ++ tests/library/chromium/connect-over-cdp.spec.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index b35fbb83f0828..fdeabb672656a 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -382,6 +382,8 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: return endpointURL; progress.log(` retrieving websocket url from ${endpointURL}`); const url = new URL(endpointURL); + if (!url.pathname.endsWith('/')) + url.pathname = url.pathname + '/'; url.pathname += 'json/version/'; const httpURL = url.toString(); diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index d5ff7915675a4..389f8838eef37 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -41,6 +41,23 @@ test('should connect to an existing cdp session', async ({ browserType, mode }, } }); +test('should connect to an existing cdp session with verbose path', async ({ browserType, mode }, testInfo) => { + const port = 9339 + testInfo.workerIndex; + const browserServer = await browserType.launch({ + args: ['--remote-debugging-port=' + port] + }); + try { + const cdpBrowser = await browserType.connectOverCDP({ + endpointURL: `http://127.0.0.1:${port}/json/version/abcdefg`, + }); + const contexts = cdpBrowser.contexts(); + expect(contexts.length).toBe(1); + await cdpBrowser.close(); + } finally { + await browserServer.close(); + } +}); + test('should use logger in default context', async ({ browserType }, testInfo) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28813' }); const port = 9339 + testInfo.workerIndex; From 0027bd97cb080220051cadc8f67ed66c3caf5404 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 20 Jun 2025 16:26:43 +0200 Subject: [PATCH 57/71] chore: browserserver, design two (#36382) --- .../playwright-core/src/client/browserType.ts | 4 +- .../src/remote/playwrightConnection.ts | 6 +- .../src/remote/playwrightServer.ts | 62 ++++++++++++++++--- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index a9116101b6cab..e41b0db69a325 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -119,8 +119,8 @@ export class BrowserType extends ChannelOwner imple }); } - connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise; - connect(wsEndpoint: string, options?: api.ConnectOptions): Promise; + connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise; + connect(wsEndpoint: string, options?: api.ConnectOptions): Promise; async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint: string }), options?: api.ConnectOptions): Promise{ if (typeof optionsOrWsEndpoint === 'string') return await this._connect({ ...options, wsEndpoint: optionsOrWsEndpoint }); diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index d92caeee78835..8a529f4b7a48a 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -26,6 +26,10 @@ import { PlaywrightDispatcherOptions } from '../server/dispatchers/playwrightDis import type { DispatcherScope, Playwright } from '../server'; import type { WebSocket } from '../utilsBundle'; +export interface PlaywrightInitializeResult extends PlaywrightDispatcherOptions { + dispose?(): Promise; +} + export class PlaywrightConnection { private _ws: WebSocket; private _semaphore: Semaphore; @@ -36,7 +40,7 @@ export class PlaywrightConnection { private _root: DispatcherScope; private _profileName: string; - constructor(semaphore: Semaphore, ws: WebSocket, controller: boolean, playwright: Playwright, initialize: () => Promise }>, id: string) { + constructor(semaphore: Semaphore, ws: WebSocket, controller: boolean, playwright: Playwright, initialize: () => Promise, id: string) { this._ws = ws; this._semaphore = semaphore; this._id = id; diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index ced787c64bc40..6eb077fa929e7 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { PlaywrightConnection } from './playwrightConnection'; +import { PlaywrightConnection, PlaywrightInitializeResult } from './playwrightConnection'; import { createPlaywright } from '../server/playwright'; import { Semaphore } from '../utils/isomorphic/semaphore'; import { DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT } from '../utils/isomorphic/time'; @@ -24,9 +24,9 @@ import { getPlaywrightVersion } from '../server/utils/userAgent'; import { debugLogger, isUnderTest } from '../utils'; import { serverSideCallMetadata } from '../server'; import { SocksProxy } from '../server/utils/socksProxy'; +import { Browser } from '../server/browser'; import type { AndroidDevice } from '../server/android/android'; -import type { Browser } from '../server/browser'; import type { Playwright } from '../server/playwright'; import type { LaunchOptions } from '../server/types'; @@ -45,10 +45,14 @@ export class PlaywrightServer { private _options: ServerOptions; private _wsServer: WSServer; + private _dontReuseBrowsers = new Set(); + constructor(options: ServerOptions) { this._options = options; - if (options.preLaunchedBrowser) + if (options.preLaunchedBrowser) { this._playwright = options.preLaunchedBrowser.attribution.playwright; + this._dontReuse(options.preLaunchedBrowser); + } if (options.preLaunchedAndroidDevice) this._playwright = options.preLaunchedAndroidDevice._android.attribution.playwright; this._playwright ??= createPlaywright({ sdkLanguage: 'javascript', isServer: true }); @@ -99,6 +103,20 @@ export class PlaywrightServer { const allowFSPaths = isExtension; launchOptions = filterLaunchOptions(launchOptions, allowFSPaths); + if (process.env.PW_BROWSER_SERVER && url.searchParams.has('connect')) { + const filter = url.searchParams.get('connect'); + if (filter !== 'first') + throw new Error(`Unknown connect filter: ${filter}`); + return new PlaywrightConnection( + browserSemaphore, + ws, + false, + this._playwright, + () => this._initConnectMode(id, filter, browserName, launchOptions), + id, + ); + } + if (isExtension) { if (url.searchParams.has('debug-controller')) { return new PlaywrightConnection( @@ -154,7 +172,7 @@ export class PlaywrightServer { }); } - private async _initReuseBrowsersMode(browserName: string | null, launchOptions: LaunchOptions, id: string) { + private async _initReuseBrowsersMode(browserName: string | null, launchOptions: LaunchOptions, id: string): Promise { // Note: reuse browser mode does not support socks proxy, because // clients come and go, while the browser stays the same. @@ -164,6 +182,8 @@ export class PlaywrightServer { let browser = this._playwright.allBrowsers().find(b => { if (b.options.name !== browserName) return false; + if (this._dontReuseBrowsers.has(b)) + return false; const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); return existingOptions === requestedOptions; }); @@ -172,6 +192,8 @@ export class PlaywrightServer { for (const b of this._playwright.allBrowsers()) { if (b === browser) continue; + if (this._dontReuseBrowsers.has(b)) + continue; if (b.options.name === browserName && b.options.channel === launchOptions.channel) await b.close({ reason: 'Connection terminated' }); } @@ -203,7 +225,25 @@ export class PlaywrightServer { }; } - private async _initPreLaunchedBrowserMode(id: string) { + private async _initConnectMode(id: string, filter: 'first', browserName: string | null, launchOptions: LaunchOptions): Promise { + browserName ??= 'chromium'; + + debugLogger.log('server', `[${id}] engaged connect mode`); + + let browser = this._playwright.allBrowsers().find(b => b.options.name === browserName); + if (!browser) { + browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), launchOptions); + this._dontReuse(browser); + } + + return { + preLaunchedBrowser: browser, + denyLaunch: true, + sharedBrowser: true, + }; + } + + private async _initPreLaunchedBrowserMode(id: string): Promise { debugLogger.log('server', `[${id}] engaged pre-launched (browser) mode`); const browser = this._options.preLaunchedBrowser!; @@ -222,7 +262,7 @@ export class PlaywrightServer { }; } - private async _initPreLaunchedAndroidMode(id: string) { + private async _initPreLaunchedAndroidMode(id: string): Promise { debugLogger.log('server', `[${id}] engaged pre-launched (Android) mode`); const androidDevice = this._options.preLaunchedAndroidDevice!; return { @@ -231,7 +271,7 @@ export class PlaywrightServer { }; } - private async _initLaunchBrowserMode(browserName: string | null, proxyValue: string | undefined, launchOptions: LaunchOptions, id: string) { + private async _initLaunchBrowserMode(browserName: string | null, proxyValue: string | undefined, launchOptions: LaunchOptions, id: string): Promise { debugLogger.log('server', `[${id}] engaged launch mode for "${browserName}"`); let socksProxy: SocksProxy | undefined; if (proxyValue) { @@ -243,6 +283,7 @@ export class PlaywrightServer { launchOptions.socksProxyPort = undefined; } const browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), launchOptions); + this._dontReuseBrowsers.add(browser); return { preLaunchedBrowser: browser, socksProxy, @@ -255,6 +296,13 @@ export class PlaywrightServer { }; } + private _dontReuse(browser: Browser) { + this._dontReuseBrowsers.add(browser); + browser.on(Browser.Events.Disconnected, () => { + this._dontReuseBrowsers.delete(browser); + }); + } + async listen(port: number = 0, hostname?: string): Promise { return this._wsServer.listen(port, hostname, this._options.path); } From d8c257f79b2729190be78ce8b6fad6664226ff16 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:48:52 +0200 Subject: [PATCH 58/71] feat(chromium): roll to r1180 (#36384) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 8 +- .../src/server/deviceDescriptorsSource.json | 108 +++++++++--------- 3 files changed, 60 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index d912d0d166770..96ab31796073a 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-138.0.7204.23-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-139.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.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-138.0.7204.35-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-139.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.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) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 138.0.7204.23 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 138.0.7204.35 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.5 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 139.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index ae4ec5d7beb55..fe6214087fb93 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1179", + "revision": "1180", "installByDefault": true, - "browserVersion": "138.0.7204.23" + "browserVersion": "138.0.7204.35" }, { "name": "chromium-headless-shell", - "revision": "1179", + "revision": "1180", "installByDefault": true, - "browserVersion": "138.0.7204.23" + "browserVersion": "138.0.7204.35" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index c42a1abf3e4b0..f260c0d5e39d9 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 480, "height": 1040 @@ -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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 1040, "height": 480 @@ -198,7 +198,7 @@ "defaultBrowserType": "chromium" }, "Galaxy A55": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 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/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 1024, "height": 640 @@ -1208,7 +1208,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1219,7 +1219,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1230,7 +1230,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1241,7 +1241,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1252,7 +1252,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1263,7 +1263,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1274,7 +1274,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/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1285,7 +1285,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/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1296,7 +1296,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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1307,7 +1307,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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1318,7 +1318,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1329,7 +1329,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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1340,7 +1340,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1351,7 +1351,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1362,7 +1362,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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1373,7 +1373,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/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1384,7 +1384,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1395,7 +1395,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1406,7 +1406,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/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1417,7 +1417,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/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1472,7 +1472,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1483,7 +1483,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1494,7 +1494,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1505,7 +1505,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1516,7 +1516,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1527,7 +1527,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/138.0.7204.23 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/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1538,7 +1538,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1549,7 +1549,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1560,7 +1560,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1575,7 +1575,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1590,7 +1590,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1605,7 +1605,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1620,7 +1620,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1635,7 +1635,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1650,7 +1650,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1661,7 +1661,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1672,7 +1672,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1687,7 +1687,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36 Edg/138.0.7204.23", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36 Edg/138.0.7204.35", "screen": { "width": 1792, "height": 1120 @@ -1732,7 +1732,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1747,7 +1747,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36 Edg/138.0.7204.23", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36 Edg/138.0.7204.35", "screen": { "width": 1920, "height": 1080 From 41bcfc998643dcd878700dac67a5cf040a06d6f0 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Sat, 21 Jun 2025 18:49:04 +0200 Subject: [PATCH 59/71] feat(webkit): roll to r2186 (#36391) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index fe6214087fb93..ddb9eea603779 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "2185", + "revision": "2186", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", From ed7e552006f03d7040d8877f569a1e1bbef79e6f Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 23 Jun 2025 08:46:04 +0200 Subject: [PATCH 60/71] chore: don't close other browsers in reuse-browsers mode (#36383) Signed-off-by: Simon Knott Co-authored-by: Dmitry Gozman --- .../src/remote/playwrightServer.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 6eb077fa929e7..9839cff9b3fcc 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -210,16 +210,13 @@ export class PlaywrightServer { denyLaunch: true, dispose: async () => { // Don't close the pages so that user could debug them, - // but close all the empty browsers and contexts to clean up. - for (const browser of this._playwright.allBrowsers()) { - for (const context of browser.contexts()) { - if (!context.pages().length) - await context.close({ reason: 'Connection terminated' }); - else - await context.stopPendingOperations('Connection closed'); - } - if (!browser.contexts()) - await browser.close({ reason: 'Connection terminated' }); + // but close all the empty contexts to clean up. + // keep around browser so it can be reused by the next connection. + for (const context of browser.contexts()) { + if (!context.pages().length) + await context.close({ reason: 'Connection terminated' }); + else + await context.stopPendingOperations('Connection closed'); } } }; From 07c49587772cf9c7cb80ccb0fd0673c46520e06c Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 23 Jun 2025 09:45:02 +0200 Subject: [PATCH 61/71] docs(clock): add snippets for 'Test with predefined time' for ports (#36393) --- docs/src/clock.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/src/clock.md b/docs/src/clock.md index 44582f450befb..e898899b18251 100644 --- a/docs/src/clock.md +++ b/docs/src/clock.md @@ -64,6 +64,47 @@ await page.clock.setFixedTime(new Date('2024-02-02T10:30:00')); await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:30:00 AM'); ``` +```python async +await page.clock.set_fixed_time(datetime.datetime(2024, 2, 2, 10, 0, 0)) +await page.goto("http://localhost:3333") +await expect(page.get_by_test_id("current-time")).to_have_text("2/2/2024, 10:00:00 AM") + +await page.clock.set_fixed_time(datetime.datetime(2024, 2, 2, 10, 30, 0)) +# We know that the page has a timer that updates the time every second. +await expect(page.get_by_test_id("current-time")).to_have_text("2/2/2024, 10:30:00 AM") +``` + +```python sync +page.clock.set_fixed_time(datetime.datetime(2024, 2, 2, 10, 0, 0)) +page.goto("http://localhost:3333") +expect(page.get_by_test_id("current-time")).to_have_text("2/2/2024, 10:00:00 AM") +page.clock.set_fixed_time(datetime.datetime(2024, 2, 2, 10, 30, 0)) +# We know that the page has a timer that updates the time every second. +expect(page.get_by_test_id("current-time")).to_have_text("2/2/2024, 10:30:00 AM") +``` + +```java +SimpleDateFormat format = new SimpleDateFormat("yyy-MM-dd'T'HH:mm:ss"); +page.clock().setFixedTime(format.parse("2024-02-02T10:00:00")); +page.navigate("http://localhost:3333"); +Locator locator = page.getByTestId("current-time"); +assertThat(locator).hasText("2/2/2024, 10:00:00 AM"); +page.clock().setFixedTime(format.parse("2024-02-02T10:30:00")); +// We know that the page has a timer that updates the time every second. +assertThat(locator).hasText("2/2/2024, 10:30:00 AM"); +``` + +```csharp +// Set the fixed time for the clock. +await Page.Clock.SetFixedTimeAsync(new DateTime(2024, 2, 2, 10, 0, 0)); +await Page.GotoAsync("http://localhost:3333"); +await Expect(Page.GetByTestId("current-time")).ToHaveTextAsync("2/2/2024, 10:00:00 AM"); +// Set the fixed time for the clock. +await Page.Clock.SetFixedTimeAsync(new DateTime(2024, 2, 2, 10, 30, 0)); +// We know that the page has a timer that updates the time every second. +await Expect(Page.GetByTestId("current-time")).ToHaveTextAsync("2/2/2024, 10:30:00 AM"); +``` + ## Consistent time and timers Sometimes your timers depend on `Date.now` and are confused when the `Date.now` value does not change over time. From 6c26c5f6ac1ba58c94e85f881ac7e0b2013f9cfa Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 09:45:07 +0200 Subject: [PATCH 62/71] feat(chromium-tip-of-tree): roll to r1342 (#36379) --- packages/playwright-core/browsers.json | 8 ++++---- tests/library/chromium/tracing.spec.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index ddb9eea603779..3b75b5f38177c 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,15 +15,15 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1341", + "revision": "1342", "installByDefault": false, - "browserVersion": "139.0.7244.0" + "browserVersion": "139.0.7248.0" }, { "name": "chromium-tip-of-tree-headless-shell", - "revision": "1341", + "revision": "1342", "installByDefault": false, - "browserVersion": "139.0.7244.0" + "browserVersion": "139.0.7248.0" }, { "name": "firefox", diff --git a/tests/library/chromium/tracing.spec.ts b/tests/library/chromium/tracing.spec.ts index a39f21ba274f6..4787fdefa8c3c 100644 --- a/tests/library/chromium/tracing.spec.ts +++ b/tests/library/chromium/tracing.spec.ts @@ -49,11 +49,12 @@ it('should create directories as needed', async ({ browser, server }, testInfo) it('should run with custom categories if provided', async ({ browser }, testInfo) => { const page = await browser.newPage(); const outputTraceFile = testInfo.outputPath(path.join(`trace.json`)); - await browser.startTracing(page, { path: outputTraceFile, categories: ['disabled-by-default-v8.cpu_profiler.hires'] }); + await browser.startTracing(page, { path: outputTraceFile, categories: ['disabled-by-default-cc.debug'] }); + await page.evaluate(() => 1 + 1); await browser.stopTracing(); const traceJson = JSON.parse(fs.readFileSync(outputTraceFile).toString()); - expect(traceJson.metadata['trace-config']).toContain('disabled-by-default-v8.cpu_profiler.hires'); + expect(traceJson.traceEvents.filter(event => event.cat === 'disabled-by-default-cc.debug').length).toBeGreaterThan(0); await page.close(); }); From 06a065de7cb95003c95d93a09d76f3099804de80 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 23 Jun 2025 11:01:14 +0100 Subject: [PATCH 63/71] test: skip `should handle timeout properly 2` on tracing bots (#36399) --- tests/page/page-set-content.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/page/page-set-content.spec.ts b/tests/page/page-set-content.spec.ts index 38f5f111cc02a..33e04c8604a30 100644 --- a/tests/page/page-set-content.spec.ts +++ b/tests/page/page-set-content.spec.ts @@ -148,7 +148,9 @@ it('should handle timeout properly', async ({ page, toImpl, browserName }) => { await expect(page.locator('div')).toHaveText('world'); }); -it('should handle timeout properly 2', async ({ page, toImpl }) => { +it('should handle timeout properly 2', async ({ page, toImpl, trace }) => { + it.skip(trace === 'on'); + await toImpl(page).mainFrame().evaluateExpression(String(() => { document.close = () => { while (true) {} From 184fb046040dcd01fe6d19843e387178cf274452 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:35:57 +0200 Subject: [PATCH 64/71] test: roll stable-test-runner to 1.54.0-alpha-2025-06-23 (#36401) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- .../stable-test-runner/package-lock.json | 46 +++++++++---------- .../stable-test-runner/package.json | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/playwright-test/stable-test-runner/package-lock.json b/tests/playwright-test/stable-test-runner/package-lock.json index 31feaa6e26246..31a57c66996d2 100644 --- a/tests/playwright-test/stable-test-runner/package-lock.json +++ b/tests/playwright-test/stable-test-runner/package-lock.json @@ -5,16 +5,16 @@ "packages": { "": { "dependencies": { - "@playwright/test": "^1.54.0-alpha-2025-06-16" + "@playwright/test": "^1.54.0-alpha-2025-06-23" } }, "node_modules/@playwright/test": { - "version": "1.54.0-alpha-2025-06-16", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.0-alpha-2025-06-16.tgz", - "integrity": "sha512-TENymjKYtOyPPFWPoJGmpB9ajjRnCjXS/DtUFNX2X1VL97K0Y+Cu15ClS+WmHD9Hpd424x6ov32qPCTdbBGdBQ==", + "version": "1.54.0-alpha-2025-06-23", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.0-alpha-2025-06-23.tgz", + "integrity": "sha512-DPbNqSuZys9IvxDMZsYynHL/BLFjGRvkpaYT98Z2cqNEgEfP1XDHKlgtm3QdzgBuHuyjAYqaTqjkscQy7MDr0g==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.54.0-alpha-2025-06-16" + "playwright": "1.54.0-alpha-2025-06-23" }, "bin": { "playwright": "cli.js" @@ -38,12 +38,12 @@ } }, "node_modules/playwright": { - "version": "1.54.0-alpha-2025-06-16", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.0-alpha-2025-06-16.tgz", - "integrity": "sha512-2w+M4qAh6XzGkeZMSp6UmuN4xfqyAFGG+zd6CiLsvTT7ZfBVuSLpDxO9N/bTBcp2auk5tmt8173OiwG1kmLXeQ==", + "version": "1.54.0-alpha-2025-06-23", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.0-alpha-2025-06-23.tgz", + "integrity": "sha512-TsaHVhZXvyaPFk0RIoxisit3PRQ3DUvwihGYKFW2GLeQJt8G5e6Oke/0VQ6VKzqltpTXJeDRIzc4MPRr9HjRDw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.54.0-alpha-2025-06-16" + "playwright-core": "1.54.0-alpha-2025-06-23" }, "bin": { "playwright": "cli.js" @@ -56,9 +56,9 @@ } }, "node_modules/playwright-core": { - "version": "1.54.0-alpha-2025-06-16", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.0-alpha-2025-06-16.tgz", - "integrity": "sha512-egCxymKutvP+lWzAQmKfHnomfAyiHpjSseZrTMn52B5hqVjW3BKbvwIU66Z/wY6ZBr6CUoRmiSChUWDd8jH/oA==", + "version": "1.54.0-alpha-2025-06-23", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.0-alpha-2025-06-23.tgz", + "integrity": "sha512-bhIwCBEYtGYxre6BZyTA1/nk8QlgfMoJy5NWoIj63j3J7QP84nIzAQDmnXiamn53CA8ajTVUTPgxo2bhE7GLKw==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -70,11 +70,11 @@ }, "dependencies": { "@playwright/test": { - "version": "1.54.0-alpha-2025-06-16", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.0-alpha-2025-06-16.tgz", - "integrity": "sha512-TENymjKYtOyPPFWPoJGmpB9ajjRnCjXS/DtUFNX2X1VL97K0Y+Cu15ClS+WmHD9Hpd424x6ov32qPCTdbBGdBQ==", + "version": "1.54.0-alpha-2025-06-23", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.0-alpha-2025-06-23.tgz", + "integrity": "sha512-DPbNqSuZys9IvxDMZsYynHL/BLFjGRvkpaYT98Z2cqNEgEfP1XDHKlgtm3QdzgBuHuyjAYqaTqjkscQy7MDr0g==", "requires": { - "playwright": "1.54.0-alpha-2025-06-16" + "playwright": "1.54.0-alpha-2025-06-23" } }, "fsevents": { @@ -84,18 +84,18 @@ "optional": true }, "playwright": { - "version": "1.54.0-alpha-2025-06-16", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.0-alpha-2025-06-16.tgz", - "integrity": "sha512-2w+M4qAh6XzGkeZMSp6UmuN4xfqyAFGG+zd6CiLsvTT7ZfBVuSLpDxO9N/bTBcp2auk5tmt8173OiwG1kmLXeQ==", + "version": "1.54.0-alpha-2025-06-23", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.0-alpha-2025-06-23.tgz", + "integrity": "sha512-TsaHVhZXvyaPFk0RIoxisit3PRQ3DUvwihGYKFW2GLeQJt8G5e6Oke/0VQ6VKzqltpTXJeDRIzc4MPRr9HjRDw==", "requires": { "fsevents": "2.3.2", - "playwright-core": "1.54.0-alpha-2025-06-16" + "playwright-core": "1.54.0-alpha-2025-06-23" } }, "playwright-core": { - "version": "1.54.0-alpha-2025-06-16", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.0-alpha-2025-06-16.tgz", - "integrity": "sha512-egCxymKutvP+lWzAQmKfHnomfAyiHpjSseZrTMn52B5hqVjW3BKbvwIU66Z/wY6ZBr6CUoRmiSChUWDd8jH/oA==" + "version": "1.54.0-alpha-2025-06-23", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.0-alpha-2025-06-23.tgz", + "integrity": "sha512-bhIwCBEYtGYxre6BZyTA1/nk8QlgfMoJy5NWoIj63j3J7QP84nIzAQDmnXiamn53CA8ajTVUTPgxo2bhE7GLKw==" } } } diff --git a/tests/playwright-test/stable-test-runner/package.json b/tests/playwright-test/stable-test-runner/package.json index fc3aa0e2e21ea..f643b0e498c6f 100644 --- a/tests/playwright-test/stable-test-runner/package.json +++ b/tests/playwright-test/stable-test-runner/package.json @@ -1,6 +1,6 @@ { "private": true, "dependencies": { - "@playwright/test": "^1.54.0-alpha-2025-06-16" + "@playwright/test": "^1.54.0-alpha-2025-06-23" } } From 669341733e901c55edddc2e974400d5cee85bef9 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 23 Jun 2025 13:55:39 +0100 Subject: [PATCH 65/71] chore: make progress strict by default (#36389) --- .github/workflows/tests_others.yml | 21 ++++++ .../src/server/android/android.ts | 4 +- .../src/server/bidi/bidiPage.ts | 4 +- .../playwright-core/src/server/browser.ts | 2 +- .../src/server/browserContext.ts | 6 +- .../playwright-core/src/server/browserType.ts | 6 +- .../src/server/chromium/chromium.ts | 14 ++-- .../src/server/chromium/crPage.ts | 5 +- .../src/server/chromium/videoRecorder.ts | 2 +- .../dispatchers/localUtilsDispatcher.ts | 5 +- packages/playwright-core/src/server/dom.ts | 37 +++++----- .../src/server/electron/electron.ts | 2 +- packages/playwright-core/src/server/fetch.ts | 2 +- .../src/server/firefox/ffPage.ts | 4 +- packages/playwright-core/src/server/frames.ts | 52 +++++++------- packages/playwright-core/src/server/input.ts | 22 +++--- packages/playwright-core/src/server/page.ts | 13 ++-- .../playwright-core/src/server/progress.ts | 69 +++++++++++-------- .../src/server/recorder/recorderApp.ts | 2 +- .../src/server/registry/index.ts | 2 +- .../server/registry/oopDownloadBrowserMain.ts | 3 +- .../src/server/screenshotter.ts | 2 +- .../src/server/trace/viewer/traceViewer.ts | 2 +- .../playwright-core/src/server/transport.ts | 4 +- .../src/server/utils/network.ts | 33 +++++---- .../src/server/webkit/wkPage.ts | 2 +- tests/library/browsertype-connect.spec.ts | 11 +++ 27 files changed, 181 insertions(+), 150 deletions(-) diff --git a/.github/workflows/tests_others.yml b/.github/workflows/tests_others.yml index ed18e1541c635..b35a61d3b234d 100644 --- a/.github/workflows/tests_others.yml +++ b/.github/workflows/tests_others.yml @@ -134,6 +134,27 @@ jobs: env: PW_CLOCK: ${{ matrix.clock }} + test_legacy_progress_timeouts: + name: legacy progress timeouts + environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} + permissions: + id-token: write # This is required for OIDC login (azure/login) to succeed + contents: read # This is required for actions/checkout to succeed + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/run-test + with: + node-version: 20 + browsers-to-install: chromium + command: npm run test -- --project=chromium-* + bot-name: "legacy-progress-timeouts-linux" + flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} + flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} + flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} + env: + PLAYWRIGHT_LEGACY_TIMEOUTS: 1 + test_electron: name: Electron - ${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 3b7e4cebf34e3..69452e4e3b442 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -260,7 +260,7 @@ export class AndroidDevice extends SdkObject { } async launchBrowser(metadata: CallMetadata, pkg: string = 'com.android.chrome', options: channels.AndroidDeviceLaunchBrowserParams): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { debug('pw:android')('Force-stopping', pkg); await this._backend.runCommand(`shell:am force-stop ${pkg}`); @@ -306,7 +306,7 @@ export class AndroidDevice extends SdkObject { } async connectToWebView(metadata: CallMetadata, socketName: string): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { const webView = this._webViews.get(socketName); if (!webView) diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index eacbe3e88f6d3..606d6fb416f4b 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -368,7 +368,7 @@ export class BidiPage implements PageDelegate { async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { const rect = (documentRect || viewportRect)!; - const { data } = await this._session.send('browsingContext.captureScreenshot', { + const { data } = await progress.race(this._session.send('browsingContext.captureScreenshot', { context: this._session.sessionId, format: { type: `image/${format === 'png' ? 'png' : 'jpeg'}`, @@ -379,7 +379,7 @@ export class BidiPage implements PageDelegate { type: 'box', ...rect, } - }); + })); return Buffer.from(data, 'base64'); } diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index d985e96fa04b0..f19c124afdfeb 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -93,7 +93,7 @@ export abstract class Browser extends SdkObject { } newContextFromMetadata(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(progress => this.newContext(progress, options)); } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index bd7e934e7ddc2..3615c3b1bdcab 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -191,7 +191,7 @@ export abstract class BrowserContext extends SdkObject { } async resetForReuse(metadata: CallMetadata, params: channels.BrowserNewContextForReuseParams | null) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(progress => this.resetForReuseImpl(progress, params)); } @@ -515,7 +515,7 @@ export abstract class BrowserContext extends SdkObject { } newPageFromMetadata(metadata: CallMetadata): Promise { - const contoller = new ProgressController(metadata, this, 'strict'); + const contoller = new ProgressController(metadata, this); return contoller.run(progress => this.newPage(progress, false)); } @@ -535,7 +535,7 @@ export abstract class BrowserContext extends SdkObject { } storageState(indexedDB = false): Promise { - const controller = new ProgressController(serverSideCallMetadata(), this, 'strict'); + const controller = new ProgressController(serverSideCallMetadata(), this); return controller.run(progress => this.storageStateImpl(progress, indexedDB)); } diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 9febf7367aa82..ba0cfa4aa148e 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -69,7 +69,7 @@ export abstract class BrowserType extends SdkObject { async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise { options = this._validateLaunchOptions(options); - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); const browser = await controller.run(progress => { const seleniumHubUrl = (options as any).__testHookSeleniumRemoteURL || process.env.SELENIUM_REMOTE_URL; if (seleniumHubUrl) @@ -81,7 +81,7 @@ export abstract class BrowserType extends SdkObject { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { timeout: number, cdpPort?: number, internalIgnoreHTTPSErrors?: boolean, socksProxyPort?: number }): Promise { const launchOptions = this._validateLaunchOptions(options); - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); const browser = await controller.run(async progress => { // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. let clientCertificatesProxy: ClientCertificatesProxy | undefined; @@ -259,7 +259,7 @@ export abstract class BrowserType extends SdkObject { close: () => closeOrKill((options as any).__testHookBrowserCloseTimeout || DEFAULT_PLAYWRIGHT_TIMEOUT), kill }; - progress.cleanupWhenAborted(() => closeOrKill(progress.timeUntilDeadline())); + progress.cleanupWhenAborted(() => closeOrKill(DEFAULT_PLAYWRIGHT_TIMEOUT)); const { wsEndpoint } = await progress.race([ this.waitForReadyState(options, browserLogsCollector), exitPromise.then(() => ({ wsEndpoint: undefined })), diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index fdeabb672656a..8ae2cdc864629 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -63,7 +63,7 @@ export class Chromium extends BrowserType { } override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray, timeout: number }) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return await this._connectOverCDPInternal(progress, endpointURL, options); }, options.timeout); @@ -199,7 +199,7 @@ export class Chromium extends BrowserType { } progress.log(` connecting to ${hubUrl}`); - const response = await fetchData({ + const response = await fetchData(progress, { url: hubUrl + 'session', method: 'POST', headers: { @@ -209,7 +209,6 @@ export class Chromium extends BrowserType { data: JSON.stringify({ capabilities: { alwaysMatch: desiredCapabilities } }), - timeout: progress.timeUntilDeadline(), }, seleniumErrorHandler); const value = JSON.parse(response).value; const sessionId = value.sessionId; @@ -217,7 +216,8 @@ export class Chromium extends BrowserType { const disconnectFromSelenium = async () => { progress.log(` disconnecting from sessionId=${sessionId}`); - await fetchData({ + // Do not pass "progress" to disconnect even after the progress has aborted. + await fetchData(undefined, { url: hubUrl + 'session/' + sessionId, method: 'DELETE', headers, @@ -253,10 +253,9 @@ export class Chromium extends BrowserType { if (endpointURL.hostname === 'localhost' || endpointURL.hostname === '127.0.0.1') { const sessionInfoUrl = new URL(hubUrl).origin + '/grid/api/testsession?session=' + sessionId; try { - const sessionResponse = await fetchData({ + const sessionResponse = await fetchData(progress, { url: sessionInfoUrl, method: 'GET', - timeout: progress.timeUntilDeadline(), headers, }, seleniumErrorHandler); const proxyId = JSON.parse(sessionResponse).proxyId; @@ -387,10 +386,9 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: url.pathname += 'json/version/'; const httpURL = url.toString(); - const json = await fetchData({ + const json = await fetchData(progress, { url: httpURL, headers, - timeout: progress.timeUntilDeadline(), }, async (_, resp) => new Error(`Unexpected status ${resp.statusCode} when connecting to ${httpURL}.\n` + `This does not look like a DevTools server, try connecting via ws://.`) ); diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 17e6182714b7e..502d135c81cad 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -256,7 +256,7 @@ export class CRPage implements PageDelegate { } async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { - const { visualViewport } = await this._mainFrameSession._client.send('Page.getLayoutMetrics'); + const { visualViewport } = await progress.race(this._mainFrameSession._client.send('Page.getLayoutMetrics')); if (!documentRect) { documentRect = { x: visualViewport.pageX + viewportRect!.x, @@ -274,8 +274,7 @@ export class CRPage implements PageDelegate { const deviceScaleFactor = this._browserContext._options.deviceScaleFactor || 1; clip.scale /= deviceScaleFactor; } - progress.throwIfAborted(); - const result = await this._mainFrameSession._client.send('Page.captureScreenshot', { format, quality, clip, captureBeyondViewport: !fitsViewport }); + const result = await progress.race(this._mainFrameSession._client.send('Page.captureScreenshot', { format, quality, clip, captureBeyondViewport: !fitsViewport })); return Buffer.from(result.data, 'base64'); } diff --git a/packages/playwright-core/src/server/chromium/videoRecorder.ts b/packages/playwright-core/src/server/chromium/videoRecorder.ts index 5b15cf5a20344..59dc50c837c07 100644 --- a/packages/playwright-core/src/server/chromium/videoRecorder.ts +++ b/packages/playwright-core/src/server/chromium/videoRecorder.ts @@ -42,7 +42,7 @@ export class VideoRecorder { if (!options.outputFile.endsWith('.webm')) throw new Error('File must have .webm extension'); - const controller = new ProgressController(serverSideCallMetadata(), page, 'strict'); + const controller = new ProgressController(serverSideCallMetadata(), page); controller.setLogName('browser'); return await controller.run(async progress => { const recorder = new VideoRecorder(page, ffmpegPath, progress); diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index e614aae6219ef..850a339564323 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -83,7 +83,7 @@ export class LocalUtilsDispatcher extends Dispatcher { - const controller = new ProgressController(metadata, this._object, 'strict'); + const controller = new ProgressController(metadata, this._object); return await controller.run(async progress => { const wsHeaders = { 'User-Agent': getUserAgent(), @@ -137,10 +137,9 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise if (!fetchUrl.pathname.endsWith('/')) fetchUrl.pathname += '/'; fetchUrl.pathname += 'json'; - const json = await fetchData({ + const json = await fetchData(progress, { url: fetchUrl.toString(), method: 'GET', - timeout: progress.timeUntilDeadline(), headers: { 'User-Agent': getUserAgent() }, }, async (params: HTTPRequestParams, response: http.IncomingMessage) => { return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 35ed3cabd2bcf..f1f234864dfe9 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -257,7 +257,7 @@ export class ElementHandle extends js.JSHandle { } async scrollIntoViewIfNeeded(metadata: CallMetadata, options: types.TimeoutOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run( progress => this._waitAndScrollIntoViewIfNeeded(progress, false /* waitForVisible */), options.timeout); @@ -336,7 +336,6 @@ export class ElementHandle extends js.JSHandle { const waitTime = [0, 20, 100, 100, 500]; while (true) { - progress.throwIfAborted(); if (retry) { progress.log(`retrying ${actionName} action${options.trial ? ' (trial run)' : ''}`); const timeout = waitTime[Math.min(retry - 1, waitTime.length - 1)]; @@ -426,7 +425,6 @@ export class ElementHandle extends js.JSHandle { // Best-effort scroll to make sure any iframes containing this element are scrolled // into view and visible, so they are not throttled. // See https://github.com/microsoft/playwright/issues/27196 for an example. - progress.throwIfAborted(); // Avoid action that has side-effects. await progress.race(doScrollIntoView().catch(() => {})); } @@ -448,7 +446,6 @@ export class ElementHandle extends js.JSHandle { await progress.race((options as any).__testHookAfterStable()); progress.log(' scrolling into view if needed'); - progress.throwIfAborted(); // Avoid action that has side-effects. const scrolled = await progress.race(doScrollIntoView()); if (scrolled !== 'done') return scrolled; @@ -494,7 +491,6 @@ export class ElementHandle extends js.JSHandle { const actionResult = await this._page.frameManager.waitForSignalsCreatedBy(progress, options.waitAfter === true, async () => { if ((options as any).__testHookBeforePointerAction) await progress.race((options as any).__testHookBeforePointerAction()); - progress.throwIfAborted(); // Avoid action that has side-effects. let restoreModifiers: types.KeyboardModifier[] | undefined; if (options && options.modifiers) restoreModifiers = await this._page.keyboard.ensureModifiers(progress, options.modifiers); @@ -538,7 +534,7 @@ export class ElementHandle extends js.JSHandle { } async hover(metadata: CallMetadata, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._hover(progress, options); @@ -551,7 +547,7 @@ export class ElementHandle extends js.JSHandle { } async click(metadata: CallMetadata, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._click(progress, { ...options, waitAfter: !options.noWaitAfter }); @@ -564,7 +560,7 @@ export class ElementHandle extends js.JSHandle { } async dblclick(metadata: CallMetadata, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._dblclick(progress, options); @@ -577,7 +573,7 @@ export class ElementHandle extends js.JSHandle { } async tap(metadata: CallMetadata, options: types.PointerActionWaitOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._tap(progress, options); @@ -590,7 +586,7 @@ export class ElementHandle extends js.JSHandle { } async selectOption(metadata: CallMetadata, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._selectOption(progress, elements, values, options); @@ -626,7 +622,7 @@ export class ElementHandle extends js.JSHandle { } async fill(metadata: CallMetadata, value: string, options: types.CommonActionOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._fill(progress, value, options); @@ -648,7 +644,6 @@ export class ElementHandle extends js.JSHandle { } return injected.fill(node, value); }, { value, force: options.force })); - progress.throwIfAborted(); // Avoid action that has side-effects. if (result === 'needsinput') { if (value) await this._page.keyboard._insertText(progress, value); @@ -662,7 +657,7 @@ export class ElementHandle extends js.JSHandle { } async selectText(metadata: CallMetadata, options: types.CommonActionOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { const result = await this._retryAction(progress, 'selectText', async () => { if (!options.force) @@ -681,7 +676,7 @@ export class ElementHandle extends js.JSHandle { } async setInputFiles(metadata: CallMetadata, params: channels.ElementHandleSetInputFilesParams) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { const inputFileItems = await progress.race(prepareFilesForUpload(this._frame, params)); await this._markAsTargetElement(progress); @@ -731,7 +726,7 @@ export class ElementHandle extends js.JSHandle { } async focus(metadata: CallMetadata): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); await controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._focus(progress); @@ -748,7 +743,7 @@ export class ElementHandle extends js.JSHandle { } async type(metadata: CallMetadata, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._type(progress, text, options); @@ -767,7 +762,7 @@ export class ElementHandle extends js.JSHandle { } async press(metadata: CallMetadata, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this._markAsTargetElement(progress); const result = await this._press(progress, key, options); @@ -788,7 +783,7 @@ export class ElementHandle extends js.JSHandle { } async check(metadata: CallMetadata, options: { position?: types.Point } & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { const result = await this._setChecked(progress, true, options); return assertDone(throwRetargetableDOMError(result)); @@ -796,7 +791,7 @@ export class ElementHandle extends js.JSHandle { } async uncheck(metadata: CallMetadata, options: { position?: types.Point } & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { const result = await this._setChecked(progress, false, options); return assertDone(throwRetargetableDOMError(result)); @@ -832,7 +827,7 @@ export class ElementHandle extends js.JSHandle { } async screenshot(metadata: CallMetadata, options: ScreenshotOptions & types.TimeoutOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run( progress => this._page.screenshotter.screenshotElement(progress, this, options), options.timeout); @@ -879,7 +874,7 @@ export class ElementHandle extends js.JSHandle { } async waitForElementState(metadata: CallMetadata, state: 'visible' | 'hidden' | 'stable' | 'enabled' | 'disabled' | 'editable', options: types.TimeoutOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { const actionName = `wait for ${state}`; const result = await this._retryAction(progress, actionName, async () => { diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index d687571d47973..b0c6e769ba18d 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -156,7 +156,7 @@ export class Electron extends SdkObject { } async launch(metadata: CallMetadata, options: channels.ElectronLaunchParams): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { let app: ElectronApplication | undefined = undefined; // --remote-debugging-port=0 must be the last playwright's argument, loader.ts relies on it. diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 26339540fa015..288d2d91b1af7 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -201,7 +201,7 @@ export abstract class APIRequestContext extends SdkObject { const postData = serializePostData(params, headers); if (postData) setHeader(headers, 'content-length', String(postData.byteLength)); - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); const fetchResponse = await controller.run(progress => { return this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries); }, params.timeout); diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index a096946457b69..c282649e7726d 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -419,12 +419,12 @@ export class FFPage implements PageDelegate { height: viewportRect!.height, }; } - const { data } = await this._session.send('Page.screenshot', { + const { data } = await progress.race(this._session.send('Page.screenshot', { mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), clip: documentRect, quality, omitDeviceScaleFactor: scale === 'css', - }); + })); return Buffer.from(data, 'base64'); } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 9e32a7d8370a2..33e738ac7ddf0 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -600,7 +600,7 @@ export class Frame extends SdkObject { } redirectNavigation(url: string, documentId: string, referer: string | undefined) { - const controller = new ProgressController(serverSideCallMetadata(), this, 'strict'); + const controller = new ProgressController(serverSideCallMetadata(), this); const data = { url, gotoPromise: controller.run(progress => this.gotoImpl(progress, url, { referer }), 0), @@ -610,7 +610,7 @@ export class Frame extends SdkObject { } async goto(metadata: CallMetadata, url: string, options: types.GotoOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(progress => { const constructedNavigationURL = constructURLBasedOnBaseURL(this._page.browserContext._options.baseURL, url); return this.raceNavigationAction(progress, async () => this.gotoImpl(progress, constructedNavigationURL, options)); @@ -744,7 +744,7 @@ export class Frame extends SdkObject { } async waitForSelector(metadata: CallMetadata, selector: string, options: types.WaitForElementOptions, scope?: dom.ElementHandle): Promise | null> { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { if ((options as any).visibility) throw new Error('options.visibility is not supported, did you mean options.state?'); @@ -871,7 +871,7 @@ export class Frame extends SdkObject { } async setContent(metadata: CallMetadata, html: string, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this.raceNavigationAction(progress, async () => { const waitUntil = options.waitUntil === undefined ? 'load' : options.waitUntil; @@ -1142,21 +1142,21 @@ export class Frame extends SdkObject { } async click(metadata: CallMetadata, selector: string, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._click(progress, { ...options, waitAfter: !options.noWaitAfter }))); }, options.timeout); } async dblclick(metadata: CallMetadata, selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._dblclick(progress, options))); }, options.timeout); } async dragAndDrop(metadata: CallMetadata, source: string, target: string, options: types.DragActionOptions & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, options.strict, !options.force /* performActionPreChecks */, async handle => { return handle._retryPointerAction(progress, 'move and down', false, async point => { @@ -1183,7 +1183,7 @@ export class Frame extends SdkObject { } async tap(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { if (!this._page.browserContext._options.hasTouch) throw new Error('The page does not support tap. Use hasTouch context option to enable touch support.'); @@ -1192,21 +1192,21 @@ export class Frame extends SdkObject { } async fill(metadata: CallMetadata, selector: string, value: string, options: types.TimeoutOptions & types.StrictOptions & { force?: boolean }) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._fill(progress, value, options))); }, options.timeout); } async focus(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._focus(progress))); }, options.timeout); } async blur(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._blur(progress))); }, options.timeout); @@ -1270,7 +1270,7 @@ export class Frame extends SdkObject { } async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { progress.log(` checking visibility of ${this._asLocator(selector)}`); return await this.isVisibleInternal(progress, selector, options, scope); @@ -1315,14 +1315,14 @@ export class Frame extends SdkObject { } async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._hover(progress, options))); }, options.timeout); } async selectOption(metadata: CallMetadata, selector: string, elements: dom.ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._selectOption(progress, elements, values, options)); }, options.timeout); @@ -1330,47 +1330,47 @@ export class Frame extends SdkObject { async setInputFiles(metadata: CallMetadata, selector: string, params: channels.FrameSetInputFilesParams): Promise { const inputFileItems = await prepareFilesForUpload(this, params); - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, params.strict, true /* performActionPreChecks */, handle => handle._setInputFiles(progress, inputFileItems))); }, params.timeout); } async type(metadata: CallMetadata, selector: string, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._type(progress, text, options))); }, options.timeout); } async press(metadata: CallMetadata, selector: string, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._press(progress, key, options))); }, options.timeout); } async check(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._setChecked(progress, true, options))); }, options.timeout); } async uncheck(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._setChecked(progress, false, options))); }, options.timeout); } async waitForTimeout(metadata: CallMetadata, timeout: number) { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(progress => progress.wait(timeout)); } async ariaSnapshot(metadata: CallMetadata, selector: string, options: { forAI?: boolean } & types.TimeoutOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => progress.race(handle.ariaSnapshot(options))); }, options.timeout); @@ -1391,7 +1391,7 @@ export class Frame extends SdkObject { const start = timeout > 0 ? monotonicTime() : 0; // Step 1: perform locator handlers checkpoint with a specified timeout. - await (new ProgressController(metadata, this, 'strict')).run(async progress => { + await (new ProgressController(metadata, this)).run(async progress => { progress.log(`${renderTitleForCall(metadata)}${timeout ? ` with timeout ${timeout}ms` : ''}`); if (selector) progress.log(`waiting for ${this._asLocator(selector)}`); @@ -1402,7 +1402,7 @@ export class Frame extends SdkObject { // Supports the case of `expect(locator).toBeVisible({ timeout: 1 })` // that should succeed when the locator is already visible. try { - const resultOneShot = await (new ProgressController(metadata, this, 'strict')).run(async progress => { + const resultOneShot = await (new ProgressController(metadata, this)).run(async progress => { return await this._expectInternal(progress, selector, options, lastIntermediateResult); }); if (resultOneShot.matches !== options.isNot) @@ -1420,7 +1420,7 @@ export class Frame extends SdkObject { return { matches: options.isNot, log: compressCallLog(metadata.log), timedOut: true, received: lastIntermediateResult.received }; // Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time. - return await (new ProgressController(metadata, this, 'strict')).run(async progress => { + return await (new ProgressController(metadata, this)).run(async progress => { return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { await this._page.performActionPreChecks(progress); const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult); @@ -1483,7 +1483,7 @@ export class Frame extends SdkObject { } async waitForFunctionExpression(metadata: CallMetadata, expression: string, isFunction: boolean | undefined, arg: any, options: types.WaitForFunctionOptions): Promise> { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(progress => this.waitForFunctionExpressionImpl(progress, expression, isFunction, arg, options, 'main'), options.timeout); } @@ -1590,7 +1590,7 @@ export class Frame extends SdkObject { } private async _callOnElementOnceMatches(metadata: CallMetadata, selector: string, body: ElementCallback, taskData: T, options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean }, scope?: dom.ElementHandle): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { const callbackText = body.toString(); progress.log(`waiting for ${this._asLocator(selector)}`); diff --git a/packages/playwright-core/src/server/input.ts b/packages/playwright-core/src/server/input.ts index 99c7fea2dc7cc..c5c34acb852aa 100644 --- a/packages/playwright-core/src/server/input.ts +++ b/packages/playwright-core/src/server/input.ts @@ -55,7 +55,7 @@ export class Keyboard { } async down(metadata: CallMetadata, key: string) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._down(progress, key)); } @@ -82,7 +82,7 @@ export class Keyboard { } async up(metadata: CallMetadata, key: string) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._up(progress, key)); } @@ -95,7 +95,7 @@ export class Keyboard { } async insertText(metadata: CallMetadata, text: string) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._insertText(progress, text)); } @@ -104,7 +104,7 @@ export class Keyboard { } async type(metadata: CallMetadata, text: string, options?: { delay?: number }) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._type(progress, text, options)); } @@ -122,7 +122,7 @@ export class Keyboard { } async press(metadata: CallMetadata, key: string, options: { delay?: number }) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._press(progress, key, options)); } @@ -214,7 +214,7 @@ export class Mouse { } async move(metadata: CallMetadata, x: number, y: number, options: { steps?: number, forClick?: boolean }) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._move(progress, x, y, options)); } @@ -232,7 +232,7 @@ export class Mouse { } async down(metadata: CallMetadata, options: { button?: types.MouseButton, clickCount?: number }) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._down(progress, options)); } @@ -244,7 +244,7 @@ export class Mouse { } async up(metadata: CallMetadata, options: { button?: types.MouseButton, clickCount?: number }) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._up(progress, options)); } @@ -256,7 +256,7 @@ export class Mouse { } async click(metadata: CallMetadata, x: number, y: number, options: { delay?: number, button?: types.MouseButton, clickCount?: number }) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._click(progress, x, y, options)); } @@ -283,7 +283,7 @@ export class Mouse { } async wheel(metadata: CallMetadata, deltaX: number, deltaY: number) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(async progress => { await this._raw.wheel(progress, this._x, this._y, this._buttons, this._keyboard._modifiers(), deltaX, deltaY); }); @@ -364,7 +364,7 @@ export class Touchscreen { } async tap(metadata: CallMetadata, x: number, y: number) { - const controller = new ProgressController(metadata, this._page, 'strict'); + const controller = new ProgressController(metadata, this._page); return controller.run(progress => this._tap(progress, x, y)); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 42ad32e9c020e..59d5bce46c03b 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -368,7 +368,7 @@ export class Page extends SdkObject { } async reload(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to reload(), // so we should await it immediately. @@ -382,7 +382,7 @@ export class Page extends SdkObject { } async goBack(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to goBack, // so we should catch it immediately. @@ -404,7 +404,7 @@ export class Page extends SdkObject { } async goForward(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to goForward, // so we should catch it immediately. @@ -451,9 +451,7 @@ export class Page extends SdkObject { async performActionPreChecks(progress: Progress) { await this._performWaitForNavigationCheck(progress); - progress.throwIfAborted(); await this._performLocatorHandlersCheckpoint(progress); - progress.throwIfAborted(); // Wait once again, just in case a locator handler caused a navigation. await this._performWaitForNavigationCheck(progress); } @@ -489,7 +487,6 @@ export class Page extends SdkObject { ++this._locatorHandlerRunningCounter; progress.log(` found ${asLocator(this.browserContext._browser.sdkLanguage(), handler.selector)}, intercepting action to run the handler`); const promise = handler.resolved.then(async () => { - progress.throwIfAborted(); if (!handler.noWaitAfter) { progress.log(` locator handler has finished, waiting for ${asLocator(this.browserContext._browser.sdkLanguage(), handler.selector)} to be hidden`); await this.mainFrame().waitForSelectorInternal(progress, handler.selector, false, { state: 'hidden' }); @@ -599,7 +596,7 @@ export class Page extends SdkObject { }; const comparator = getComparator('image/png'); - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); if (!options.expected && options.isNot) return { errorMessage: '"not" matcher requires expected result' }; try { @@ -812,7 +809,7 @@ export class Page extends SdkObject { } snapshotForAI(metadata: CallMetadata): Promise { - const controller = new ProgressController(metadata, this, 'strict'); + const controller = new ProgressController(metadata, this); return controller.run(async progress => { this.lastSnapshotFrameIds = []; const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), 0, this.lastSnapshotFrameIds); diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index e01576bbc8a00..4e4c8a0ac4f1e 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -15,17 +15,30 @@ */ import { TimeoutError } from './errors'; -import { assert, monotonicTime } from '../utils'; +import { assert } from '../utils'; import { ManualPromise } from '../utils/isomorphic/manualPromise'; import type { CallMetadata, Instrumentation, SdkObject } from './instrumentation'; import type { LogName } from './utils/debugLogger'; +// Most server operations are run inside a Progress instance. +// Each method that takes a Progress must result in one of the three outcomes: +// - It finishes successfully, returning a value, before the Progress is aborted. +// - It throws some error, before the Progress is aborted. +// - It throws the Progress's aborted error, because the Progress was aborted before +// the method could finish. +// As a rule of thumb, the above is achieved by: +// - Passing the Progress instance when awaiting other methods. +// - Using `progress.race()` when awaiting other methods that do not take a Progress argument. +// In this case, it is important that awaited method has no side effects, for example +// it is a read-only browser protocol call. +// - In rare cases, when the awaited method does not take a Progress argument, +// but it does have side effects such as creating a page - a proper cleanup +// must be taken in case Progress is aborted before the awaited method finishes. +// That's usually done by `progress.raceWithCleanup()` or `progress.cleanupWhenAborted()`. export interface Progress { log(message: string): void; - timeUntilDeadline(): number; - cleanupWhenAborted(cleanup: () => any): void; - throwIfAborted(): void; + cleanupWhenAborted(cleanup: (error: Error | undefined) => any): void; race(promise: Promise | Promise[]): Promise; raceWithCleanup(promise: Promise, cleanup: (result: T) => any): Promise; wait(timeout: number): Promise; @@ -37,7 +50,7 @@ export class ProgressController { private _donePromise = new ManualPromise(); // Cleanups to be run only in the case of abort. - private _cleanups: (() => any)[] = []; + private _cleanups: ((error: Error | undefined) => any)[] = []; // Lenient mode races against the timeout. This guarantees that timeout is respected, // but may have some work being done after the timeout due to parallel control flow. @@ -48,13 +61,12 @@ export class ProgressController { private _logName: LogName; private _state: 'before' | 'running' | { error: Error } | 'finished' = 'before'; - private _deadline: number = 0; readonly metadata: CallMetadata; readonly instrumentation: Instrumentation; readonly sdkObject: SdkObject; - constructor(metadata: CallMetadata, sdkObject: SdkObject, strictMode?: 'strict') { - this._strictMode = strictMode === 'strict'; + constructor(metadata: CallMetadata, sdkObject: SdkObject) { + this._strictMode = !process.env.PLAYWRIGHT_LEGACY_TIMEOUTS; this.metadata = metadata; this.sdkObject = sdkObject; this.instrumentation = sdkObject.instrumentation; @@ -77,8 +89,6 @@ export class ProgressController { } async run(task: (progress: Progress) => Promise, timeout?: number): Promise { - this._deadline = timeout ? monotonicTime() + timeout : 0; - assert(this._state === 'before'); this._state = 'running'; this.sdkObject.attribution.context?._activeProgressControllers.add(this); @@ -90,16 +100,17 @@ export class ProgressController { // Note: we might be sending logs after progress has finished, for example browser logs. this.instrumentation.onCallLog(this.sdkObject, this.metadata, this._logName, message); }, - timeUntilDeadline: () => this._deadline ? this._deadline - monotonicTime() : 2147483647, // 2^31-1 safe setTimeout in Node. - cleanupWhenAborted: (cleanup: () => any) => { + cleanupWhenAborted: (cleanup: (error: Error | undefined) => any) => { + if (this._strictMode) { + if (this._state !== 'running') + throw new Error('Internal error: cannot register cleanup after operation has finished.'); + this._cleanups.push(cleanup); + return; + } if (this._state === 'running') this._cleanups.push(cleanup); else - runCleanup(cleanup); - }, - throwIfAborted: () => { - if (typeof this._state === 'object') - throw this._state.error; + runCleanup(typeof this._state === 'object' ? this._state.error : undefined, cleanup); }, metadata: this.metadata, race: (promise: Promise | Promise[]) => { @@ -119,13 +130,17 @@ export class ProgressController { }, }; - const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded.`); - const timer = setTimeout(() => { - if (this._state === 'running') { - this._state = { error: timeoutError }; - this._forceAbortPromise.reject(timeoutError); - } - }, progress.timeUntilDeadline()); + let timer: NodeJS.Timeout | undefined; + if (timeout) { + const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded.`); + timer = setTimeout(() => { + if (this._state === 'running') { + this._state = { error: timeoutError }; + this._forceAbortPromise.reject(timeoutError); + } + }, Math.min(timeout, 2147483647)); // 2^31-1 safe setTimeout in Node. + } + try { const promise = task(progress); const result = this._strictMode ? await promise : await Promise.race([promise, this._forceAbortPromise]); @@ -133,7 +148,7 @@ export class ProgressController { return result; } catch (error) { this._state = { error }; - await Promise.all(this._cleanups.splice(0).map(runCleanup)); + await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(error, cleanup))); throw error; } finally { this.sdkObject.attribution.context?._activeProgressControllers.delete(this); @@ -143,9 +158,9 @@ export class ProgressController { } } -async function runCleanup(cleanup: () => any) { +async function runCleanup(error: Error | undefined, cleanup: (error: Error | undefined) => any) { try { - await cleanup(); + await cleanup(error); } catch (e) { } } diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index f9aea392e86da..5f69e225f2cfc 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -121,7 +121,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { timeout: 0, } }); - const controller = new ProgressController(serverSideCallMetadata(), context._browser, 'strict'); + const controller = new ProgressController(serverSideCallMetadata(), context._browser); await controller.run(async progress => { await context._browser._defaultContext!._loadDefaultContextAsIs(progress); }); diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 4a0e7cb989be7..433a6f35f6ca9 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -1197,7 +1197,7 @@ export class Registry { private async _installMSEdgeChannel(channel: 'msedge'|'msedge-beta'|'msedge-dev', scripts: Record<'linux' | 'darwin' | 'win32', string>) { const scriptArgs: string[] = []; if (process.platform !== 'linux') { - const products = lowercaseAllKeys(JSON.parse(await fetchData({ url: 'https://edgeupdates.microsoft.com/api/products' }))); + const products = lowercaseAllKeys(JSON.parse(await fetchData(undefined, { url: 'https://edgeupdates.microsoft.com/api/products' }))); const productName = { 'msedge': 'Stable', diff --git a/packages/playwright-core/src/server/registry/oopDownloadBrowserMain.ts b/packages/playwright-core/src/server/registry/oopDownloadBrowserMain.ts index c0619d95e429b..9c59d0e6f2c97 100644 --- a/packages/playwright-core/src/server/registry/oopDownloadBrowserMain.ts +++ b/packages/playwright-core/src/server/registry/oopDownloadBrowserMain.ts @@ -48,13 +48,12 @@ function downloadFile(options: DownloadParams): Promise { let totalBytes = 0; const promise = new ManualPromise(); - httpRequest({ url: options.url, headers: { 'User-Agent': options.userAgent, }, - timeout: options.socketTimeout, + socketTimeout: options.socketTimeout, }, response => { log(`-- response status code: ${response.statusCode}`); if (response.statusCode !== 200) { diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index 306857ee2836e..ea8b09e4864a1 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -303,7 +303,7 @@ export class Screenshotter { const cleanupHighlight = await this._maskElements(progress, options); const quality = format === 'jpeg' ? options.quality ?? 80 : undefined; - const buffer = await progress.race(this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device')); + const buffer = await this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device'); await cleanupHighlight(); if (shouldSetDefaultBackground) diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index c202d65e6fc38..68a5fdd5c76b7 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -180,7 +180,7 @@ export async function openTraceViewerApp(url: string, browserName: string, optio }, }); - const controller = new ProgressController(serverSideCallMetadata(), context._browser, 'strict'); + const controller = new ProgressController(serverSideCallMetadata(), context._browser); await controller.run(async progress => { await context._browser._defaultContext!._loadDefaultContextAsIs(progress); }); diff --git a/packages/playwright-core/src/server/transport.ts b/packages/playwright-core/src/server/transport.ts index 30e87ae0c2eca..c2fefad6d2070 100644 --- a/packages/playwright-core/src/server/transport.ts +++ b/packages/playwright-core/src/server/transport.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { DEFAULT_PLAYWRIGHT_TIMEOUT, makeWaitForNextTask } from '../utils'; +import { makeWaitForNextTask } from '../utils'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './utils/happyEyeballs'; import { ws } from '../utilsBundle'; @@ -133,8 +133,6 @@ export class WebSocketTransport implements ConnectionTransport { this._logUrl = logUrl; this._ws = new ws(url, [], { maxPayload: 256 * 1024 * 1024, // 256Mb, - // Prevent internal http client error when passing negative timeout. - handshakeTimeout: Math.max(progress?.timeUntilDeadline() ?? DEFAULT_PLAYWRIGHT_TIMEOUT, 1), headers: options.headers, followRedirects: options.followRedirects, agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent, diff --git a/packages/playwright-core/src/server/utils/network.ts b/packages/playwright-core/src/server/utils/network.ts index 5afa861280352..d12b72a1c389e 100644 --- a/packages/playwright-core/src/server/utils/network.ts +++ b/packages/playwright-core/src/server/utils/network.ts @@ -25,19 +25,20 @@ import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from './happyEyeballs import type net from 'net'; import type { ProxySettings } from '../types'; +import type { Progress } from '../progress'; export type HTTPRequestParams = { url: string, method?: string, headers?: http.OutgoingHttpHeaders, data?: string | Buffer, - timeout?: number, rejectUnauthorized?: boolean, + socketTimeout?: number, }; export const NET_DEFAULT_TIMEOUT = 30_000; -export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.IncomingMessage) => void, onError: (error: Error) => void) { +export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.IncomingMessage) => void, onError: (error: Error) => void): { cancel(error: Error | undefined): void } { const parsedUrl = url.parse(params.url); let options: https.RequestOptions = { ...parsedUrl, @@ -48,8 +49,6 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco if (params.rejectUnauthorized !== undefined) options.rejectUnauthorized = params.rejectUnauthorized; - const timeout = params.timeout ?? NET_DEFAULT_TIMEOUT; - const proxyURL = getProxyForUrl(params.url); if (proxyURL) { const parsedProxyURL = url.parse(proxyURL); @@ -69,13 +68,14 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco } } + let cancelRequest: (e: Error | undefined) => void; const requestCallback = (res: http.IncomingMessage) => { const statusCode = res.statusCode || 0; if (statusCode >= 300 && statusCode < 400 && res.headers.location) { // Close the original socket before following the redirect. Otherwise // it may stay idle and cause a timeout error. request.destroy(); - httpRequest({ ...params, url: new URL(res.headers.location, params.url).toString() }, onResponse, onError); + cancelRequest = httpRequest({ ...params, url: new URL(res.headers.location, params.url).toString() }, onResponse, onError).cancel; } else { onResponse(res); } @@ -84,23 +84,20 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco https.request(options, requestCallback) : http.request(options, requestCallback); request.on('error', onError); - if (timeout !== undefined) { - const rejectOnTimeout = () => { - onError(new Error(`Request to ${params.url} timed out after ${timeout}ms`)); + if (params.socketTimeout !== undefined) { + request.setTimeout(params.socketTimeout, () => { + onError(new Error(`Request to ${params.url} timed out after ${params.socketTimeout}ms`)); request.abort(); - }; - if (timeout <= 0) { - rejectOnTimeout(); - return; - } - request.setTimeout(timeout, rejectOnTimeout); + }); } + cancelRequest = e => request.destroy(e); request.end(params.data); + return { cancel: e => cancelRequest(e) }; } -export function fetchData(params: HTTPRequestParams, onError?: (params: HTTPRequestParams, response: http.IncomingMessage) => Promise): Promise { - return new Promise((resolve, reject) => { - httpRequest(params, async response => { +export function fetchData(progress: Progress | undefined, params: HTTPRequestParams, onError?: (params: HTTPRequestParams, response: http.IncomingMessage) => Promise): Promise { + const promise = new Promise((resolve, reject) => { + const { cancel } = httpRequest(params, async response => { if (response.statusCode !== 200) { const error = onError ? await onError(params, response) : new Error(`fetch failed: server returned code ${response.statusCode}. URL: ${params.url}`); reject(error); @@ -111,7 +108,9 @@ export function fetchData(params: HTTPRequestParams, onError?: (params: HTTPRequ response.on('error', (error: any) => reject(error)); response.on('end', () => resolve(body)); }, reject); + progress?.cleanupWhenAborted(cancel); }); + return progress ? progress.race(promise) : promise; } function shouldBypassProxy(url: URL, bypass?: string): boolean { diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 99707016843c5..36f66daaa7c68 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -869,7 +869,7 @@ export class WKPage implements PageDelegate { const omitDeviceScaleFactor = scale === 'css'; this.validateScreenshotDimension(rect.width, omitDeviceScaleFactor); this.validateScreenshotDimension(rect.height, omitDeviceScaleFactor); - const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor }); + const result = await progress.race(this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor })); const prefix = 'data:image/png;base64,'; let buffer: Buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); if (format === 'jpeg') diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index 1eb1342e8d5b5..9efe3b0f98de4 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -1054,3 +1054,14 @@ test('should refuse connecting when versions do not match', async ({ connect, ch expect(error.message).toContain('server version: v1.2'); expect(error.message).toContain('client version: v' + getPlaywrightVersion(true)); }); + +test('should timeout after redirect when connecting over http', async ({ connect, server }) => { + server.setRedirect('/connect/json', '/connect/slow'); + let aborted = false; + server.setRoute('/connect/slow', (req, res) => { + req.socket.on('close', () => aborted = true); + }); + const error = await connect(`${server.PREFIX}/connect`, { timeout: 2000, headers: { 'Connection': 'Close' } }).catch(e => e); + expect(error.message).toContain('Timeout 2000ms exceeded.'); + await expect.poll(() => aborted).toBe(true); +}); From 5013e2c1c8526a41ecb03b324a5491da7b829ec5 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 23 Jun 2025 16:35:56 +0200 Subject: [PATCH 66/71] test: chromium tracing test rebase (#36403) --- tests/library/chromium/tracing.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/library/chromium/tracing.spec.ts b/tests/library/chromium/tracing.spec.ts index 4787fdefa8c3c..3baac85902a0b 100644 --- a/tests/library/chromium/tracing.spec.ts +++ b/tests/library/chromium/tracing.spec.ts @@ -54,7 +54,11 @@ it('should run with custom categories if provided', async ({ browser }, testInfo await browser.stopTracing(); const traceJson = JSON.parse(fs.readFileSync(outputTraceFile).toString()); - expect(traceJson.traceEvents.filter(event => event.cat === 'disabled-by-default-cc.debug').length).toBeGreaterThan(0); + expect( + // NOTE: trace-config is deprecated as per http://crrev.com/c/6628182 + traceJson.metadata['trace-config']?.includes('disabled-by-default-cc.debug') || + traceJson.traceEvents.filter(event => event.cat === 'disabled-by-default-cc.debug').length > 0 + ).toBe(true); await page.close(); }); From a5b68a5c0e87ff2bbd0582cb39f4d150108340ab Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 23 Jun 2025 17:25:43 +0200 Subject: [PATCH 67/71] devops: remove redundant scripts (#36408) --- utils/check_chromium_cdn.js | 196 ------------------------ utils/doclint/generateJavaSnippets.js | 154 ------------------- utils/doclint/generatePythonSnippets.js | 120 --------------- utils/doclint/since.js | 81 ---------- utils/draft_release_notes.sh | 39 ----- utils/limits.sh | 2 - utils/list_closed_issues.sh | 25 --- utils/ts_to_java.js | 165 -------------------- 8 files changed, 782 deletions(-) delete mode 100755 utils/check_chromium_cdn.js delete mode 100644 utils/doclint/generateJavaSnippets.js delete mode 100644 utils/doclint/generatePythonSnippets.js delete mode 100644 utils/doclint/since.js delete mode 100755 utils/draft_release_notes.sh delete mode 100755 utils/limits.sh delete mode 100755 utils/list_closed_issues.sh delete mode 100644 utils/ts_to_java.js diff --git a/utils/check_chromium_cdn.js b/utils/check_chromium_cdn.js deleted file mode 100755 index c509624152dcd..0000000000000 --- a/utils/check_chromium_cdn.js +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env node -/** - * Copyright 2017 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const assert = require('assert'); -const https = require('https'); -const util = require('util'); -const URL = require('url'); -const SUPPORTER_PLATFORMS = ['linux', 'mac', 'win64']; - -const colors = { - reset: '\x1b[0m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m' -}; - -class Table { - /** - * @param {!Array} columnWidths - */ - constructor(columnWidths) { - this.widths = columnWidths; - } - - /** - * @param {!Array} values - */ - drawRow(values) { - assert(values.length === this.widths.length); - let row = ''; - for (let i = 0; i < values.length; ++i) - row += padCenter(values[i], this.widths[i]); - console.log(row); - } -} - -if (process.argv.length === 2) { - checkOmahaProxyAvailability(); - return; -} -if (process.argv.length !== 4) { - console.log(` - Usage: node check_revisions.js [fromRevision] [toRevision] - -This script checks availability of different prebuild chromium revisions. -Running command without arguments will check against omahaproxy revisions.`); - return; -} - -const fromRevision = parseInt(process.argv[2], 10); -const toRevision = parseInt(process.argv[3], 10); -checkRangeAvailability(fromRevision, toRevision, false /* stopWhenAllAvailable */); - -async function checkOmahaProxyAvailability() { - const lastchanged = (await Promise.all([ - fetch('https://storage.googleapis.com/chromium-browser-snapshots/Mac/LAST_CHANGE'), - fetch('https://storage.googleapis.com/chromium-browser-snapshots/Linux_x64/LAST_CHANGE'), - fetch('https://storage.googleapis.com/chromium-browser-snapshots/Win/LAST_CHANGE'), - fetch('https://storage.googleapis.com/chromium-browser-snapshots/Win_x64/LAST_CHANGE'), - ])).map(s => parseInt(s, 10)); - const from = Math.max(...lastchanged); - checkRangeAvailability(from, 0, true /* stopWhenAllAvailable */); -} - -/** - * @param {number} fromRevision - * @param {number} toRevision - * @param {boolean} stopWhenAllAvailable - */ -async function checkRangeAvailability(fromRevision, toRevision, stopWhenAllAvailable) { - const table = new Table([15, 7, 7, 7]); - table.drawRow([''].concat(SUPPORTER_PLATFORMS)); - const inc = fromRevision < toRevision ? 1 : -1; - for (let revision = fromRevision; revision !== toRevision; revision += inc) { - const allAvailable = await checkAndDrawRevisionAvailability(table, 'chromium', revision); - if (allAvailable && stopWhenAllAvailable) - break; - } -} - -async function canDownload(revision, platform) { - const serverHost = 'https://storage.googleapis.com'; - const urlTemplate = new Map([ - ['linux', '%s/chromium-browser-snapshots/Linux_x64/%d/chrome-linux.zip'], - ['mac', '%s/chromium-browser-snapshots/Mac/%d/chrome-mac.zip'], - ['win64', '%s/chromium-browser-snapshots/Win_x64/%d/chrome-win.zip'], - ]).get(platform); - assert(urlTemplate, `ERROR: Playwright does not support ${platform}`); - const url = util.format(urlTemplate, serverHost, revision); - return await headRequest(url); -} - -/** - * @param {!Table} table - * @param {string} name - * @param {number} revision - * @return {boolean} - */ -async function checkAndDrawRevisionAvailability(table, name, revision) { - const promises = SUPPORTER_PLATFORMS.map(platform => canDownload(revision, platform)); - const availability = await Promise.all(promises); - const allAvailable = availability.every(e => !!e); - const values = [name + ' ' + (allAvailable ? colors.green + revision + colors.reset : revision)]; - for (let i = 0; i < availability.length; ++i) { - const decoration = availability[i] ? '+' : '-'; - const color = availability[i] ? colors.green : colors.red; - values.push(color + decoration + colors.reset); - } - table.drawRow(values); - return allAvailable; -} - -/** - * @param {string} url - * @return {!Promise} - */ -function fetch(url) { - let resolve; - const promise = new Promise(x => resolve = x); - https.get(url, response => { - if (response.statusCode !== 200) { - resolve(null); - return; - } - let body = ''; - response.on('data', function(chunk){ - body += chunk; - }); - response.on('end', function(){ - resolve(body); - }); - }).on('error', function(e){ - console.error('Error fetching json: ' + e); - resolve(null); - }); - return promise; -} - -/** - * @param {number} size - * @return {string} - */ -function spaceString(size) { - return new Array(size).fill(' ').join(''); -} - -/** - * @param {string} text - * @return {string} - */ -function filterOutColors(text) { - for (const colorName in colors) { - const color = colors[colorName]; - text = text.replace(color, ''); - } - return text; -} - -/** - * @param {string} text - * @param {number} length - * @return {string} - */ -function padCenter(text, length) { - const printableCharacters = filterOutColors(text); - if (printableCharacters.length >= length) - return text; - const left = Math.floor((length - printableCharacters.length) / 2); - const right = Math.ceil((length - printableCharacters.length) / 2); - return spaceString(left) + text + spaceString(right); -} - -async function headRequest(url) { - return new Promise(resolve => { - let options = URL.parse(url); - options.method = 'HEAD'; - const request = https.request(options, res => resolve(res.statusCode === 200)); - request.on('error', error => resolve(false)); - request.end(); - }); -} - diff --git a/utils/doclint/generateJavaSnippets.js b/utils/doclint/generateJavaSnippets.js deleted file mode 100644 index 097381131728e..0000000000000 --- a/utils/doclint/generateJavaSnippets.js +++ /dev/null @@ -1,154 +0,0 @@ -// @ts-check - -const fs = require("fs"); -const md = require("../markdown"); - - -/** - * @param {string[]} input - */ -function transformValue(input) { - const out = []; - const suffix = []; - for (let line of input) { - let match = line.match(/const { (\w+) } = require\('playwright'\);/); - if (match) { - out.push('import com.microsoft.playwright.*;'); - out.push(''); - out.push('public class Example {'); - out.push(' public static void main(String[] args) {'); - out.push(' try (Playwright playwright = Playwright.create()) {'); - out.push(` BrowserType ${match[1]} = playwright.${match[1]}();`); - suffix.push(' }'); - suffix.push(' }'); - suffix.push('}'); - continue; - } - if (line.trim() === '(async () => {' || line.trim() === '})();') - continue; - if (!line) - continue; - if (line.trim() === '}') - continue; - - // Remove await/Promise.all - line = line.replace(/const \[(.+)\] = await Promise.all\(\[/g, '$1 ='); - line = line.replace(/Promise\.all\(\[/g, ''); - line = line.replace(/await /g, ''); - - // Rename some methods - line = line.replace(/\.goto\(/g, '.navigate('); - line = line.replace(/\.continue\(/g, '.resume('); - line = line.replace(/\.\$eval\(/g, '.evalOnSelector('); - line = line.replace(/\.\$\$eval\(/g, '.evalOnSelectorAll('); - line = line.replace(/\.\$\(/g, '.querySelector('); - line = line.replace(/\.\$\$\(/g, '.querySelectorAll('); - - line = line.replace(/console.log/g, 'System.out.println'); - - line = line.replace(/page.evaluate\((\(\) => [^\)]+)\)/g, 'page.evaluate("$1")'); - - // Convert properties to methods - line = line.replace(/\.keyboard\./g, '.keyboard().'); - line = line.replace(/\.mouse\./g, '.mouse().'); - line = line.replace(/\.coverage\./g, '.coverage().'); - line = line.replace(/\.accessibility\./g, '.accessibility().'); - line = line.replace(/\.chromium\./g, '.chromium().'); - line = line.replace(/\.webkit\./g, '.webkit().'); - line = line.replace(/\.firefox\./g, '.firefox().'); - line = line.replace(/\.length/g, '.size()'); - - // JUnit asserts - line = line.replace(/expect\((.+)\).toBeTruthy\(\);/g, 'assertNotNull($1);'); - line = line.replace(/expect\(error.message\)\.toContain\((.+)\);/g, 'assertTrue(e.getMessage().contains($1));'); - line = line.replace(/expect\((.+)\)\.toContain\((.+)\);/g, 'assertTrue($1.contains($2));'); - line = line.replace(/expect\((.+)\)\.toBe\(null\);/g, 'assertNull($1);'); - line = line.replace(/expect\((.+)\)\.not.toBe\(null\);/g, 'assertNotNull($1);'); - line = line.replace(/expect\((.+)\)\.toBe\(true\);/g, 'assertTrue($1);'); - line = line.replace(/expect\((.+)\)\.toBe\((.+)\);/g, 'assertEquals($2, $1);'); - line = line.replace(/expect\((.+)\)\.toEqual\(\[(.+)\]\);/g, 'assertEquals(Arrays.asList($2), $1);'); - line = line.replace(/expect\((.+)\)\.toEqual\((.+)\);/g, 'assertEquals($2, $1);'); - - line = line.replace(/\[('[^']+')\]/g, '.get("$1")'); - line = line.replace(/.push\(/g, '.add('); - - // Define common types - line = line.replace(/const browser = /g, 'Browser browser = '); - line = line.replace(/const context = /g, 'BrowserContext context = '); - line = line.replace(/const page = /g, 'Page page = '); - line = line.replace(/const newPage = /g, 'Page newPage = '); - line = line.replace(/const button/g, 'ElementHandle button'); - line = line.replace(/const result = /g, 'Object result = '); - line = line.replace(/const response = /g, 'Response response = '); - line = line.replace(/const request = /g, 'Request request = '); - line = line.replace(/const requests = \[\];/g, 'List requests = new ArrayList<>();'); - line = line.replace(/const snapshot = page.accessibility/g, 'String snapshot = page.accessibility'); - line = line.replace(/snapshot\.children\./g, 'snapshot.children().'); - line = line.replace(/const (.+) = \[\];/g, 'List<> $1 = new ArrayList<>();'); - line = line.replace(/const (\w+ = .+evalOnSelector)/g, 'Object $1'); - line = line.replace(/const (\w+ = .+querySelector)/g, 'ElementHandle $1'); - line = line.replace(/const (.+= page.waitForNavigation)/g, 'Response $1'); - line = line.replace(/const messages = \[\]/g, 'List messages = new ArrayList<>()'); - line = line.replace(/const frame = /g, 'Frame frame = '); - line = line.replace(/const elementHandle = (.+)/g, 'JSHandle jsHandle = $1\n ElementHandle elementHandle = jsHandle.asElement();\n'); - line = line.replace(/const (\w+ = \w+\.boundingBox)/g, 'BoundingBox $1'); - line = line.replace(/setViewportSize\({ width: (\d+), height: (\d+) }\)/g, 'setViewportSize($1, $2)'); - line = line.replace(/\.on\('([^']+)'/g, (match, p1, offset, string) => `.on${toTitleCase(p1)}(`); - line = line.replace(/\.waitForEvent\('([^']+)'/g, (match, p1, offset, string) => `page.waitFor${toTitleCase(p1)}(() -> {})`); - - line = line.replace(/[`']/g, '"'); - - out.push(line) - } - return [...out, ...suffix].join("\n"); -} - -/** - * @param {string} name - */ -function toTitleCase(name) { - return name[0].toUpperCase() + name.substring(1); -} - -/** - * @param {md.MarkdownNode} node - */ -function generateComment(node) { - const commentNode = md.clone(node) - commentNode.codeLang = 'java'; - commentNode.lines = ['// FIXME', ...transformValue(node.lines).split("\n")]; - return commentNode; -} - -/** - * - * @param {md.MarkdownNode[]} spec - */ -function multiplyComment(spec) { - const children = [] - for (const node of (spec || [])) { - if (node.codeLang === "js") - children.push(node, generateComment(node)); - else - children.push(node); - } - return children; -} - -for (const name of fs.readdirSync("docs/src")) { - if (!name.endsWith(".md")) - continue; - if (name.includes('android')) - continue; - const inputFile = `docs/src/${name}`; - const fileline = fs.readFileSync(inputFile).toString(); - const nodes = md.parse(fileline); - - md.visitAll(nodes, node => { - if (node.children) - node.children = multiplyComment(node.children); - }); - - const out = md.render(nodes, 120); - fs.writeFileSync(inputFile, out); -} diff --git a/utils/doclint/generatePythonSnippets.js b/utils/doclint/generatePythonSnippets.js deleted file mode 100644 index 98275966cced0..0000000000000 --- a/utils/doclint/generatePythonSnippets.js +++ /dev/null @@ -1,120 +0,0 @@ -// @ts-check - -const fs = require("fs"); -const md = require("../markdown"); - - -/** - * @param {string[]} input - * @param {boolean} isSync - */ -function transformValue(input, isSync) { - const out = []; - const suffix = []; - for (let line of input) { - let match = line.match(/const { (\w+) } = require\('playwright'\);/); - if (match) { - if (isSync) { - out.push('from playwright.sync_api import sync_playwright, Playwright'); - out.push(''); - out.push('def run(playwright: Playwright):'); - out.push(` ${match[1]} = playwright.${match[1]}`); - suffix.push(``); - suffix.push(`with sync_playwright() as playwright:`); - suffix.push(` run(playwright)`); - } else { - out.push('import asyncio'); - out.push('from playwright.async_api import async_playwright, Playwright'); - out.push(''); - out.push('async def run(playwright: Playwright):'); - out.push(` ${match[1]} = playwright.${match[1]}`); - suffix.push(``); - suffix.push(`async def main():`); - suffix.push(` async with async_playwright() as playwright:`); - suffix.push(` await run(playwright)`); - suffix.push(`asyncio.run(main())`); - } - continue; - } - if (line.trim() === '(async () => {' || line.trim() === '})();') - continue; - if (!line) - continue; - if (line.trim() === '}') - continue; - line = line.replace(/\$\$eval/g, 'eval_on_selector_all'); - line = line.replace(/\$eval/g, 'eval_on_selector'); - line = line.replace(/\$\$/g, 'query_selector_all'); - line = line.replace(/\$/g, 'query_selector'); - line = line.replace(/([a-zA-Z$]+)/g, (match, p1) => toSnakeCase(p1)); - line = line.replace(/try {/, 'try:'); - line = line.replace(/async \(([^)]+)\) => {/, 'lambda $1:'); - line = line.replace(/} catch \(e\) {/, 'except Error as e:'); - line = line.replace(/;$/, ''); - line = line.replace(/ /g, ' '); - line = line.replace(/'/g, '"'); - line = line.replace(/const /g, ''); - line = line.replace(/{\s*(\w+):\s*([^} ]+)\s*}/, "$1=$2"); - line = line.replace(/\/\/ /, "# "); - line = line.replace(/\(\) => /, 'lambda: '); - line = line.replace(/console.log/, 'print'); - line = line.replace(/function /, 'def '); - line = line.replace(/{$/, ''); - if (isSync) - line = line.replace(/await /g, "") - out.push(line) - } - return [...out, ...suffix].join("\n"); -} - -/** - * - * @param {md.MarkdownNode} node - * @param {boolean} isSync - */ -function generateComment(node, isSync) { - const commentNode = md.clone(node) - commentNode.codeLang = isSync ? "python sync" : "python async"; - commentNode.lines = ['# FIXME', ...transformValue(node.lines, isSync).split("\n")]; - return commentNode; -} - -/** - * - * @param {md.MarkdownNode[]} spec - */ -function multiplyComment(spec) { - const children = [] - for (const node of (spec || [])) { - if (node.codeLang === "js") - children.push(node, generateComment(node, false), generateComment(node, true)); - else - children.push(node); - } - return children; -} - -/** - * @param {string} name - */ -function toSnakeCase(name) { - const toSnakeCaseRegex = /((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))/g; - return name.replace(toSnakeCaseRegex, `_$1`).toLowerCase(); -} - -for (const name of fs.readdirSync("docs/src")) { - if (!name.endsWith(".md")) - continue; - const inputFile = `docs/src/${name}`; - const fileContent = fs.readFileSync(inputFile).toString(); - const nodes = md.parse(fileContent); - - md.visitAll(nodes, node => { - if (node.children) - node.children = multiplyComment(node.children); - }); - - - const out = md.render(nodes, 120); - fs.writeFileSync(inputFile, out); -} diff --git a/utils/doclint/since.js b/utils/doclint/since.js deleted file mode 100644 index 5f97ec563071a..0000000000000 --- a/utils/doclint/since.js +++ /dev/null @@ -1,81 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { ZipFile } = require('../../packages/playwright-core/lib/utils'); - -(async () => { - // Extract API.json - if (!process.env.DRIVERS_DIR) { - console.log('DRIVERS_DIR env should contain downloaded drivers'); - process.exit(1); - } - - for (const name of fs.readdirSync(process.env.DRIVERS_DIR)) { - const match = name.match(/playwright-(.*).0-linux.zip/); - if (!match) - continue; - const apiName = path.join(process.env.DRIVERS_DIR, `api-${match[1]}.json`); - if (!fs.existsSync(apiName)) { - const zipFile = new ZipFile(path.join(process.env.DRIVERS_DIR, name)); - const buffer = await zipFile.read('package/api.json'); - fs.writeFileSync(path.join(process.env.DRIVERS_DIR, `api-${match[1]}.json`), buffer); - zipFile.close(); - } - } - - // Build Since map. - const since = new Map(); - for (const name of fs.readdirSync(process.env.DRIVERS_DIR)) { - const match = name.match(/api-(.*).json/); - if (!match) - continue; - const version = match[1]; - const json = JSON.parse(fs.readFileSync(path.join(process.env.DRIVERS_DIR, `api-${match[1]}.json`), 'utf-8')); - for (const clazz of json) { - add(since, `# class: ${clazz.name}`, version); - for (const member of clazz.members) { - add(since, `## ${member.async ? 'async ' : ''}${member.kind}: ${clazz.name}.${member.name}`, version); - for (const arg of member.args) { - if (arg.name === 'options') { - for (const option of arg.type.properties) - add(since, `### option: ${clazz.name}.${member.name}.${option.name}`, version); - } else { - add(since, `### param: ${clazz.name}.${member.name}.${arg.name}`, version); - } - } - } - } - } - - // Patch docs - for (const name of fs.readdirSync('docs/src/api')) { - const lines = fs.readFileSync(path.join('docs/src/api', name), 'utf-8'); - const toPatch = new Map(); - for (const line of lines.split('\n')) { - if (!line.startsWith('# class:') && !line.startsWith('## method:') && !line.startsWith('## async method:') && !line.startsWith('## property:') && !line.startsWith('## event:') && !line.startsWith('### param:') && !line.startsWith('### option:')) - continue; - const key = line.includes('=') ? line.substring(0, line.indexOf('=')).trim() : line; - const version = since.get(key); - console.log(key); - - if (!version) { - console.log('Not yet released: ' + line); - continue; - } - - toPatch.set(line +'\n', line + `\n* since: v${version}\n`); - } - if (toPatch.size) { - let newContent = lines; - for (const [from, to] of toPatch) - newContent = newContent.replace(new RegExp(from, 'g'), to); - fs.writeFileSync(path.join('docs/src/api', name), newContent); - } - } - -})(); - -function add(since, name, version) { - let v = since.get(name); - if (!v || (+v.split('.')[1]) > (+version.split('.')[1])) - since.set(name, version); -} diff --git a/utils/draft_release_notes.sh b/utils/draft_release_notes.sh deleted file mode 100755 index c27df96a4aaf8..0000000000000 --- a/utils/draft_release_notes.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -set -e -set +x - -trap "cd $(pwd -P)" EXIT -cd "$(dirname $0)" - -git fetch --tags git@github.com:microsoft/playwright.git >/dev/null 2>/dev/null -LAST_RELEASE=$(git describe --tags $(git rev-list --tags --max-count=1)) - -echo "## Highlights" -echo -echo "TODO: asked teammates for the highlights" -echo -echo "## Browser Versions" -echo -node ./print_versions.js -echo -echo "## New APIs" -echo -echo "TODO: \`git diff -w ${LAST_RELEASE}..HEAD src/client\`" -echo -CLOSED_ISSUES=$(./list_closed_issues.sh "${LAST_RELEASE}") -ISSUES_COUNT=$(echo "${CLOSED_ISSUES}" | wc -l | xargs) -echo "
" -echo " Issues Closed (${ISSUES_COUNT})" -echo -echo "${CLOSED_ISSUES}" -echo -echo "
" - -COMMITS=$(git log --pretty="%h - %s" "${LAST_RELEASE}"..HEAD) -COMMITS_COUNT=$(echo "${COMMITS}" | wc -l | xargs) -echo "
" -echo " Commits (${COMMITS_COUNT})" -echo -echo "${COMMITS}" -echo -echo "
" diff --git a/utils/limits.sh b/utils/limits.sh deleted file mode 100755 index 71baec19a4b8f..0000000000000 --- a/utils/limits.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -sudo sysctl -w fs.inotify.max_user_instances=1024 diff --git a/utils/list_closed_issues.sh b/utils/list_closed_issues.sh deleted file mode 100755 index 512da442835b9..0000000000000 --- a/utils/list_closed_issues.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -e -set +x - -if [[ ($1 == '--help') || ($1 == '-h') ]]; then - echo "usage: $(basename $0) " - echo - echo "List Playwright closed issues since the given commit was landed" - echo - echo "Example: $(basename $0) HEAD~100" - exit 0 -fi - -if [[ $# == 0 ]]; then - echo "missing git SHA" - echo "try './$(basename $0) --help' for more information" - exit 1 -fi - -COMMIT_DATE_WEIRD_ISO=$(git show -s --format=%cd --date=iso $1) -COMMIT_DATE=$(node -e "console.log(new Date('${COMMIT_DATE_WEIRD_ISO}').toISOString())") - -curl -s "https://api.github.com/repos/microsoft/playwright/issues?state=closed&since=${COMMIT_DATE}&direction=asc&per_page=100" | \ - node -e "console.log(JSON.parse(require('fs').readFileSync(0, 'utf8')).filter(issue => !issue.pull_request && new Date(issue.closed_at) > new Date('${COMMIT_DATE}')).map(issue => '#' + issue.number + ' - ' + issue.title).join('\n'))" - diff --git a/utils/ts_to_java.js b/utils/ts_to_java.js deleted file mode 100644 index d071945a9fa73..0000000000000 --- a/utils/ts_to_java.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// The script does basic transformation of .spec.ts code to .java unit tests. - -const path = require('path'); -const fs = require('fs'); -const os = require('os'); -const util = require('util'); -const { argv } = require('process'); - -(async () => { - if (process.argv.length < 3) throw new Error("Usage: node to_java.js .spec.js"); - const file = argv[2]; - if (!file.endsWith('.spec.ts')) throw new Error("Unexpected input: " + file); - console.log('Reading: ' + file); - let content = await util.promisify(fs.readFile)(file); - content = content.toString(); - - function toCamelCase(match, p1, offset, string) { - return p1.toUpperCase(); - } - - function itReplacer(match, p1, p2, p3, offset, string) { - // let name = p1.replace(/[ :'\-=\[\]\<\>]/g, '_'); - let name = p1.replace(/\W+(.)/g, toCamelCase); - // Remove special chars from the end. - name = name.replace(/\W+$/, ''); - // console.log(name); - return `@Test -void ${name}() {`; - } - content = content.replace(/playwrightTest\('(.+)',.*{/g, itReplacer); - content = content.replace(/browserTest\('(.+)',.*{/g, itReplacer); - content = content.replace(/pageTest\('(.+)',.*{/g, itReplacer); - content = content.replace(/test\('(.+)',.*{/g, itReplacer); - content = content.replace(/it\('(.+)',.*{/g, itReplacer); - content = content.replace(/it\(`(.+)`,.*{/g, itReplacer); - - // Test's closing bracket: }); - content = content.replace(/\n\}\);/g, '\n}'); - - content = content.replace(/ async route => {/g, ' (route, request) -> {'); - content = content.replace(/ route => {/g, ' (route, request) -> {'); - content = content.replace(/ async \(route, request\) => {/g, ' (route, request) -> {'); - content = content.replace(/ \(route, request\) => {/g, ' (route, request) -> {'); - content = content.replace(/(server.setRoute.+)\(req, res\) => \{/g, '$1exchange -> {'); - - content = content.replace(/([^\\])"/g, '$1SINGLE_QUOTE'); - // content = content.replace(/(\) \=\>.*)'/g, '$1 XXX'); - - // Replace single quotes with double quotes - content = content.replace(/''/g, '""'); - content = content.replace(/(?.*)([^\\])'/g, '$1"'); - // Replace double quotes with single quotes - content = content.replace(/SINGLE_QUOTE/g, "'"); - content = content.replace(/`/g, '"'); - - // quote lambdas - content = content.replace(/request => requests.push\(request\)/g, 'request -> requests.add(request)'); - // content = content.replace(/, ([^,\(]+ \=\> [^\)]+)\)/g, ', "$1")'); - content = content.replace(/page.evaluate\((\(\) => [^\)]+)\)/g, 'page.evaluate("$1")'); - - // Remove await/Promise.all - // Match all [^;] inside Promise.all([...]); to overcome greedy match and not include next foo([...]) calls. - content = content.replace(/const \[(.+)\] = await Promise.all\(\[([^;]+|)\]\);/g, '$1 = $2'); - content = content.replace(/await Promise.all\(\[([^;]+|)\]\);/g, '$1'); - content = content.replace(/Promise\.all\(\[/g, ''); - content = content.replace(/await /g, ''); - - // Rename some methods - content = content.replace(/context\.tracing/g, 'context.tracing()'); - content = content.replace(/\.goto\(/g, '.navigate('); - content = content.replace(/\.continue\(/g, '.resume('); - content = content.replace(/\.\$eval\(/g, '.evalOnSelector('); - content = content.replace(/\.\$\$eval\(/g, '.evalOnSelectorAll('); - content = content.replace(/\.\$\(/g, '.querySelector('); - content = content.replace(/\.\$\$\(/g, '.querySelectorAll('); - - content = content.replace(/\.keyboard\./g, '.keyboard().'); - content = content.replace(/\.mouse\./g, '.mouse().'); - content = content.replace(/\.coverage\./g, '.coverage().'); - content = content.replace(/\.accessibility\./g, '.accessibility().'); - content = content.replace(/\.length/g, '.size()'); - - content = content.replace(/expect\((.+)\).toBeTruthy\(\);/g, 'assertNotNull($1);'); - content = content.replace(/expect\(error.message\)\.toContain\((.+)\);/g, 'assertTrue(e.getMessage().contains($1), e.getMessage());'); - content = content.replace(/expect\((.+)\)\.toContain\((.+)\);/g, 'assertTrue($1.contains($2));'); - content = content.replace(/expect\((.+)\)\.toBe\(null\);/g, 'assertNull($1);'); - content = content.replace(/expect\((.+)\)\.not.toBe\(null\);/g, 'assertNotNull($1);'); - content = content.replace(/expect\((.+\.evaluate.+)\)\.toBe\(true\);/g, 'assertEquals(true, $1);'); - content = content.replace(/expect\((.+)\)\.toBe\(true\);/g, 'assertTrue($1);'); - content = content.replace(/expect\((.+)\)\.toBe\((.+)\);/g, 'assertEquals($2, $1);'); - // Match all [^;] inside .toEqual([...]); to overcome greedy match and not include next foo([...]) calls. - content = content.replace(/expect\((.+)\)\.toEqual\(\[([^;]+|)\]\);/g, 'assertEquals(asList($2), $1);'); - for (let before = null; before !== content;) { - before = content; - content = content.replace(/(asList\([^\)\']*)'/g, '$1"'); - } - content = content.replace(/expect\((.+)\)\.toEqual\((.+)\);/g, 'assertEquals($2, $1);'); - - content = content.replace(/(? requests = new ArrayList<>();'); - content = content.replace(/const snapshot = page.accessibility/g, 'AccessibilityNode snapshot = page.accessibility'); - content = content.replace(/snapshot\.children\./g, 'snapshot.children().'); - content = content.replace(/const (.+) = \[\];/g, 'List<> $1 = new ArrayList<>();'); - content = content.replace(/const (\w+ = .+evalOnSelector)/g, 'Object $1'); - content = content.replace(/const (\w+ = .+querySelector)/g, 'ElementHandle $1'); - content = content.replace(/const messages = \[\]/g, 'List messages = new ArrayList<>()'); - content = content.replace(/const frame = /g, 'Frame frame = '); - content = content.replace(/const elementHandle = (.+)/g, 'JSHandle jsHandle = $1\n ElementHandle elementHandle = jsHandle.asElement();\n assertNotNull(elementHandle);'); - content = content.replace(/const (\w+ = \w+\.boundingBox)/g, 'ElementHandle.BoundingBox $1'); - content = content.replace(/assertEquals\({ x: (\d+), y: (\d+), width: (\d+), height: (\d+) }, box\);/g, `assertEquals(box.x, $1); - assertEquals(box.y, $2); - assertEquals(box.width, $3); - assertEquals(box.height, $4);`); - content = content.replace(/setViewportSize\({ width: (\d+), height: (\d+) }\)/g, 'setViewportSize($1, $2)'); - content = content.replace(/\.on\("([^"]+)", /g, (match, p1, offset, string) => `\.on${toTitleCase(p1)}(`); - content = content.replace(/page.waitForEvent\("([^"]+)"/g, (match, p1, offset, string) => `page.waitFor${toTitleCase(p1)}(`); - content = content.replace(/server.waitForRequest/g, 'server.futureRequest'); - content = content.replace(/context.request/g, 'context.request()'); - content = content.replace(/page.request/g, 'page.request()'); - content = content.replace(/playwright.request/g, 'playwright.request()'); - - // try/catch - content = content.replace(/const error = /g, 'try {\n'); - content = content.replace(/\.catch\(e => e\)[;,]/g, ';\nfail("did not throw");\n} catch (PlaywrightException e) {}\n'); - content = content.replace(/(.+)\.catch\(e => error = e\);/g, ' try {\n $1;\n fail("did not throw");\n } catch (PlaywrightException e) {\n }\n'); - - const output = file.replace(/\.spec\.ts$/, ".java") - console.log('Writing: ' + output); - await util.promisify(fs.writeFile)(output, content) -})(); - -function toTitleCase(s) { - return s[0].toUpperCase() + s.substr(1); -} \ No newline at end of file From 68d7f66566db8230ab3adb9efd61b9d98f78c0c6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 23 Jun 2025 08:39:47 -0700 Subject: [PATCH 68/71] chore: move Page.close() tests to tests/library (#36390) --- tests/library/page-close.spec.ts | 231 ++++++++++++++++++++ tests/page/expect-timeout.spec.ts | 10 - tests/page/page-add-locator-handler.spec.ts | 18 -- tests/page/page-basic.spec.ts | 89 -------- tests/page/page-click.spec.ts | 10 - tests/page/page-close.spec.ts | 38 ---- tests/page/page-event-network.spec.ts | 21 -- tests/page/page-event-popup.spec.ts | 16 -- tests/page/page-expose-function.spec.ts | 17 -- tests/page/page-network-response.spec.ts | 20 -- tests/page/page-request-continue.spec.ts | 27 --- 11 files changed, 231 insertions(+), 266 deletions(-) create mode 100644 tests/library/page-close.spec.ts delete mode 100644 tests/page/page-close.spec.ts diff --git a/tests/library/page-close.spec.ts b/tests/library/page-close.spec.ts new file mode 100644 index 0000000000000..f8b712fc433fc --- /dev/null +++ b/tests/library/page-close.spec.ts @@ -0,0 +1,231 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { stripAnsi } from '../config/utils'; +import { browserTest as test, expect } from '../config/browserTest'; +import { kTargetClosedErrorMessage } from '../config/errors'; + +test('should close page with active dialog', async ({ page }) => { + await page.evaluate('"trigger builtins.setTimeout"'); + await page.setContent(``); + void page.click('button').catch(() => {}); + await page.waitForEvent('dialog'); + await page.close(); +}); + +test('should not accept dialog after close', async ({ page, mode }) => { + test.fixme(mode.startsWith('service2'), 'Times out'); + const promise = page.waitForEvent('dialog'); + page.evaluate(() => alert()).catch(() => {}); + const dialog = await promise; + await page.close(); + const e = await dialog.dismiss().catch(e => e); + expect(e.message).toContain('Target page, context or browser has been closed'); +}); + +test('expect should not print timed out error message when page closes', async ({ page }) => { + await page.setContent('
Text content
'); + const [error] = await Promise.all([ + expect(page.locator('div')).toHaveText('hey', { timeout: 100000 }).catch(e => e), + page.close(), + ]); + expect(stripAnsi(error.message)).toContain(`expect(locator).toHaveText(expected)`); + expect(stripAnsi(error.message)).not.toContain('Timed out'); +}); + +test('addLocatorHandler should throw when page closes', async ({ page, server }) => { + await page.goto(server.PREFIX + '/input/handle-locator.html'); + + await page.addLocatorHandler(page.getByText('This interstitial covers the button'), async () => { + await page.close(); + }); + + await page.locator('#aside').hover(); + await page.evaluate(() => { + (window as any).clicked = 0; + (window as any).setupAnnoyingInterstitial('mouseover', 1); + }); + const error = await page.locator('#target').click().catch(e => e); + expect(error.message).toContain(kTargetClosedErrorMessage); +}); + +test('should reject all promises when page is closed', async ({ page }) => { + let error = null; + await Promise.all([ + page.evaluate(() => new Promise(r => {})).catch(e => error = e), + page.close(), + ]); + expect(error.message).toContain(kTargetClosedErrorMessage); +}); + +test('should set the page close state', async ({ page }) => { + expect(page.isClosed()).toBe(false); + await page.close(); + expect(page.isClosed()).toBe(true); +}); + +test('should pass page to close event', async ({ page }) => { + const [closedPage] = await Promise.all([ + page.waitForEvent('close'), + page.close() + ]); + expect(closedPage).toBe(page); +}); + +test('should terminate network waiters', async ({ page, server }) => { + const results = await Promise.all([ + page.waitForRequest(server.EMPTY_PAGE).catch(e => e), + page.waitForResponse(server.EMPTY_PAGE).catch(e => e), + page.close() + ]); + for (let i = 0; i < 2; i++) { + const message = results[i].message; + expect(message).toContain(kTargetClosedErrorMessage); + expect(message).not.toContain('Timeout'); + } +}); + +test('should be callable twice', async ({ page }) => { + await Promise.all([ + page.close(), + page.close(), + ]); + await page.close(); +}); + +test('should return null if parent page has been closed', async ({ page }) => { + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.evaluate(() => window.open('about:blank')), + ]); + await page.close(); + const opener = await popup.opener(); + expect(opener).toBe(null); +}); + +test('should fail with error upon disconnect', async ({ page }) => { + let error; + const waitForPromise = page.waitForEvent('download').catch(e => error = e); + await page.close(); + await waitForPromise; + expect(error.message).toContain(kTargetClosedErrorMessage); +}); + +test('page.close should work with window.close', async function({ page }) { + const closedPromise = new Promise(x => page.on('close', x)); + await page.close(); + await closedPromise; +}); + +test('should not throw UnhandledPromiseRejection when page closes', async ({ page, browserName, isWindows }) => { + test.fixme(browserName === 'firefox' && isWindows, 'makes the next test to always timeout'); + + await Promise.all([ + page.close(), + page.mouse.click(1, 2), + ]).catch(e => {}); +}); + +test('interrupt request.response() and request.allHeaders() on page.close', async ({ page, server, browserName }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27227' }); + server.setRoute('/one-style.css', (req, res) => { + res.setHeader('Content-Type', 'text/css'); + }); + const reqPromise = page.waitForRequest('**/one-style.css'); + await page.goto(server.PREFIX + '/one-style.html', { waitUntil: 'domcontentloaded' }); + const req = await reqPromise; + const respPromise = req.response().catch(e => e); + const headersPromise = req.allHeaders().catch(e => e); + await page.close(); + expect((await respPromise).message).toContain(kTargetClosedErrorMessage); + // All headers are the same as "provisional" headers in Firefox. + if (browserName === 'firefox') + expect((await headersPromise)['user-agent']).toBeTruthy(); + else + expect((await headersPromise).message).toContain(kTargetClosedErrorMessage); +}); + +test('should not treat navigations as new popups', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent('
yo'); + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.click('a'), + ]); + let badSecondPopup = false; + page.on('popup', () => badSecondPopup = true); + await popup.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + await page.close(); + expect(badSecondPopup).toBe(false); +}); + +test('should not result in unhandled rejection', async ({ page }) => { + const closedPromise = page.waitForEvent('close'); + await page.exposeFunction('foo', async () => { + await page.close(); + }); + await page.evaluate(() => { + window.builtins.setTimeout(() => (window as any).foo(), 0); + return undefined; + }); + await closedPromise; + // Make a round-trip to be sure we did not throw immediately after closing. + expect(await page.evaluate('1 + 1').catch(e => e)).toBeInstanceOf(Error); +}); + +test('should reject response.finished if page closes', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + server.setRoute('/get', (req, res) => { + // In Firefox, |fetch| will be hanging until it receives |Content-Type| header + // from server. + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.write('hello '); + }); + // send request and wait for server response + const [pageResponse] = await Promise.all([ + page.waitForEvent('response'), + page.evaluate(() => fetch('./get', { method: 'GET' })), + ]); + + const finishPromise = pageResponse.finished().catch(e => e); + await page.close(); + const error = await finishPromise; + expect(error.message).toContain('closed'); +}); + +test('should not throw when continuing while page is closing', async ({ page, server }) => { + let done; + await page.route('**/*', async route => { + done = Promise.all([ + void route.continue(), + page.close(), + ]); + }); + await page.goto(server.EMPTY_PAGE).catch(e => e); + await done; +}); + +test('should not throw when continuing after page is closed', async ({ page, server }) => { + let done; + await page.route('**/*', async route => { + await page.close(); + done = route.continue(); + }); + const error = await page.goto(server.EMPTY_PAGE).catch(e => e); + await done; + expect(error).toBeInstanceOf(Error); +}); diff --git a/tests/page/expect-timeout.spec.ts b/tests/page/expect-timeout.spec.ts index d8a062dd25b7a..e048ec744df5f 100644 --- a/tests/page/expect-timeout.spec.ts +++ b/tests/page/expect-timeout.spec.ts @@ -41,16 +41,6 @@ test('should print timed out error message when value does not match with imposs expect(stripAnsi(error.message)).toContain(`Timed out 1ms waiting for expect(locator).toHaveText(expected)`); }); -test('should not print timed out error message when page closes', async ({ page }) => { - await page.setContent('
Text content
'); - const [error] = await Promise.all([ - expect(page.locator('div')).toHaveText('hey', { timeout: 100000 }).catch(e => e), - page.close(), - ]); - expect(stripAnsi(error.message)).toContain(`expect(locator).toHaveText(expected)`); - expect(stripAnsi(error.message)).not.toContain('Timed out'); -}); - test('should have timeout error name', async ({ page }) => { const error = await page.waitForSelector('#not-found', { timeout: 1 }).catch(e => e); expect(error.name).toBe('TimeoutError'); diff --git a/tests/page/page-add-locator-handler.spec.ts b/tests/page/page-add-locator-handler.spec.ts index 605e63da821e6..069dde024158b 100644 --- a/tests/page/page-add-locator-handler.spec.ts +++ b/tests/page/page-add-locator-handler.spec.ts @@ -15,7 +15,6 @@ */ import { test, expect } from './pageTest'; -import { kTargetClosedErrorMessage } from '../config/errors'; test('should work', async ({ page, server }) => { await page.goto(server.PREFIX + '/input/handle-locator.html'); @@ -121,23 +120,6 @@ test('should not work with force:true', async ({ page, server }) => { expect(await page.evaluate('window.clicked')).toBe(undefined); }); -test('should throw when page closes', async ({ page, server, isAndroid }) => { - test.fixme(isAndroid, 'GPU process crash: https://issues.chromium.org/issues/324909825'); - await page.goto(server.PREFIX + '/input/handle-locator.html'); - - await page.addLocatorHandler(page.getByText('This interstitial covers the button'), async () => { - await page.close(); - }); - - await page.locator('#aside').hover(); - await page.evaluate(() => { - (window as any).clicked = 0; - (window as any).setupAnnoyingInterstitial('mouseover', 1); - }); - const error = await page.locator('#target').click().catch(e => e); - expect(error.message).toContain(kTargetClosedErrorMessage); -}); - test('should throw when handler times out', async ({ page, server }) => { await page.goto(server.PREFIX + '/input/handle-locator.html'); diff --git a/tests/page/page-basic.spec.ts b/tests/page/page-basic.spec.ts index 0b99d0c8c5f64..d8e8d40dec7ce 100644 --- a/tests/page/page-basic.spec.ts +++ b/tests/page/page-basic.spec.ts @@ -15,66 +15,8 @@ * limitations under the License. */ -import { kTargetClosedErrorMessage } from '../config/errors'; import { test as it, expect } from './pageTest'; -it('should reject all promises when page is closed', async ({ page, isWebView2, isAndroid }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - it.fixme(isAndroid, '"Target crashed" instead of "Target closed"'); - - let error = null; - await Promise.all([ - page.evaluate(() => new Promise(r => {})).catch(e => error = e), - page.close(), - ]); - expect(error.message).toContain(kTargetClosedErrorMessage); -}); - -it('should set the page close state', async ({ page, isWebView2 }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - expect(page.isClosed()).toBe(false); - await page.close(); - expect(page.isClosed()).toBe(true); -}); - -it('should pass page to close event', async ({ page, isAndroid, isWebView2 }) => { - it.fixme(isAndroid); - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - const [closedPage] = await Promise.all([ - page.waitForEvent('close'), - page.close() - ]); - expect(closedPage).toBe(page); -}); - -it('should terminate network waiters', async ({ page, server, isAndroid, isWebView2 }) => { - it.fixme(isAndroid); - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - const results = await Promise.all([ - page.waitForRequest(server.EMPTY_PAGE).catch(e => e), - page.waitForResponse(server.EMPTY_PAGE).catch(e => e), - page.close() - ]); - for (let i = 0; i < 2; i++) { - const message = results[i].message; - expect(message).toContain(kTargetClosedErrorMessage); - expect(message).not.toContain('Timeout'); - } -}); - -it('should be callable twice', async ({ page, isWebView2 }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - await Promise.all([ - page.close(), - page.close(), - ]); - await page.close(); -}); - it('should fire load when expected', async ({ page }) => { await Promise.all([ page.goto('about:blank'), @@ -101,18 +43,6 @@ it('should provide access to the opener page', async ({ page }) => { expect(opener).toBe(page); }); -it('should return null if parent page has been closed', async ({ page, isWebView2 }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - const [popup] = await Promise.all([ - page.waitForEvent('popup'), - page.evaluate(() => window.open('about:blank')), - ]); - await page.close(); - const opener = await popup.opener(); - expect(opener).toBe(null); -}); - it('should fire domcontentloaded when expected', async ({ page }) => { const navigatedPromise = page.goto('about:blank'); await page.waitForEvent('domcontentloaded'); @@ -135,17 +65,6 @@ it('should pass self as argument to load event', async ({ page }) => { expect(eventArg).toBe(page); }); -it('should fail with error upon disconnect', async ({ page, isAndroid, isWebView2 }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - it.fixme(isAndroid); - - let error; - const waitForPromise = page.waitForEvent('download').catch(e => error = e); - await page.close(); - await waitForPromise; - expect(error.message).toContain(kTargetClosedErrorMessage); -}); - it('page.url should work', async ({ page, server }) => { expect(page.url()).toBe('about:blank'); await page.goto(server.EMPTY_PAGE); @@ -175,14 +94,6 @@ it('page.close should work with window.close', async function({ page }) { await closedPromise; }); -it('page.close should work with page.close', async function({ page, isWebView2 }) { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - const closedPromise = new Promise(x => page.on('close', x)); - await page.close(); - await closedPromise; -}); - it('page.frame should respect name', async function({ page }) { await page.setContent(``); expect(page.frame({ name: 'bogus' })).toBe(null); diff --git a/tests/page/page-click.spec.ts b/tests/page/page-click.spec.ts index b206480740c8b..f048ea6c5710b 100644 --- a/tests/page/page-click.spec.ts +++ b/tests/page/page-click.spec.ts @@ -85,16 +85,6 @@ it('should click on a span with an inline element inside', async ({ page }) => { expect(await page.evaluate('CLICKED')).toBe(42); }); -it('should not throw UnhandledPromiseRejection when page closes', async ({ page, isWebView2, browserName, isWindows }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - it.fixme(browserName === 'firefox' && isWindows, 'makes the next test to always timeout'); - - await Promise.all([ - page.close(), - page.mouse.click(1, 2), - ]).catch(e => {}); -}); - it('should click the aligned 1x1 div', async ({ page }) => { await page.setContent(`
`); await page.click('div'); diff --git a/tests/page/page-close.spec.ts b/tests/page/page-close.spec.ts deleted file mode 100644 index fd215f8b60400..0000000000000 --- a/tests/page/page-close.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright 2017 Google Inc. All rights reserved. - * Modifications copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { test as it, expect } from './pageTest'; - -it.skip(({ isWebView2 }) => isWebView2, 'Page.close() is not supported in WebView2'); - -it('should close page with active dialog', async ({ page }) => { - await page.evaluate('"trigger builtins.setTimeout"'); - await page.setContent(``); - void page.click('button').catch(() => {}); - await page.waitForEvent('dialog'); - await page.close(); -}); - -it('should not accept dialog after close', async ({ page, mode }) => { - it.fixme(mode.startsWith('service2'), 'Times out'); - const promise = page.waitForEvent('dialog'); - page.evaluate(() => alert()).catch(() => {}); - const dialog = await promise; - await page.close(); - const e = await dialog.dismiss().catch(e => e); - expect(e.message).toContain('Target page, context or browser has been closed'); -}); diff --git a/tests/page/page-event-network.spec.ts b/tests/page/page-event-network.spec.ts index 8aa1462d5a71e..e7004f576a245 100644 --- a/tests/page/page-event-network.spec.ts +++ b/tests/page/page-event-network.spec.ts @@ -17,7 +17,6 @@ import type { ServerResponse } from 'http'; import { test as it, expect } from './pageTest'; -import { kTargetClosedErrorMessage } from '../config/errors'; it('Page.Events.Request @smoke', async ({ page, server }) => { const requests = []; @@ -138,23 +137,3 @@ it('should resolve responses after a navigation', async ({ page, server, browser // the response should resolve to null, because the page navigated. expect(await responsePromise).toBe(null); }); - -it('interrupt request.response() and request.allHeaders() on page.close', async ({ page, server, browserName }) => { - it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27227' }); - server.setRoute('/one-style.css', (req, res) => { - res.setHeader('Content-Type', 'text/css'); - }); - const reqPromise = page.waitForRequest('**/one-style.css'); - await page.goto(server.PREFIX + '/one-style.html', { waitUntil: 'domcontentloaded' }); - const req = await reqPromise; - const respPromise = req.response().catch(e => e); - const headersPromise = req.allHeaders().catch(e => e); - await page.close(); - expect((await respPromise).message).toContain(kTargetClosedErrorMessage); - // All headers are the same as "provisional" headers in Firefox. - if (browserName === 'firefox') - expect((await headersPromise)['user-agent']).toBeTruthy(); - else - expect((await headersPromise).message).toContain(kTargetClosedErrorMessage); - -}); diff --git a/tests/page/page-event-popup.spec.ts b/tests/page/page-event-popup.spec.ts index 07b203709ae38..152136acdebf5 100644 --- a/tests/page/page-event-popup.spec.ts +++ b/tests/page/page-event-popup.spec.ts @@ -146,22 +146,6 @@ it('should work with clicking target=_blank and rel=noopener', async ({ page, se expect(await popup.evaluate(() => !!window.opener)).toBe(false); }); -it('should not treat navigations as new popups', async ({ page, server, isWebView2 }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - await page.goto(server.EMPTY_PAGE); - await page.setContent('yo'); - const [popup] = await Promise.all([ - page.waitForEvent('popup'), - page.click('a'), - ]); - let badSecondPopup = false; - page.on('popup', () => badSecondPopup = true); - await popup.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); - await page.close(); - expect(badSecondPopup).toBe(false); -}); - it('should report popup opened from iframes', async ({ page, server, browserName }) => { await page.goto(server.PREFIX + '/frames/two-frames.html'); const frame = page.frame('uno'); diff --git a/tests/page/page-expose-function.spec.ts b/tests/page/page-expose-function.spec.ts index b16546ef61f94..e9334c4df08d3 100644 --- a/tests/page/page-expose-function.spec.ts +++ b/tests/page/page-expose-function.spec.ts @@ -221,23 +221,6 @@ it('exposeBindingHandle should throw for multiple arguments', async ({ page }) = expect(error.message).toContain('exposeBindingHandle supports a single argument, 2 received'); }); -it('should not result in unhandled rejection', async ({ page, isAndroid, isWebView2 }) => { - it.fixme(isAndroid); - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - const closedPromise = page.waitForEvent('close'); - await page.exposeFunction('foo', async () => { - await page.close(); - }); - await page.evaluate(() => { - window.builtins.setTimeout(() => (window as any).foo(), 0); - return undefined; - }); - await closedPromise; - // Make a round-trip to be sure we did not throw immediately after closing. - expect(await page.evaluate('1 + 1').catch(e => e)).toBeInstanceOf(Error); -}); - it('exposeBinding(handle) should work with element handles', async ({ page }) => { let cb; const promise = new Promise(f => cb = f); diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index 1224713b2cfc4..e353aa6956daa 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -109,26 +109,6 @@ it('should wait until response completes', async ({ page, server }) => { expect(await responseText).toBe('hello world!'); }); -it('should reject response.finished if page closes', async ({ page, server }) => { - await page.goto(server.EMPTY_PAGE); - server.setRoute('/get', (req, res) => { - // In Firefox, |fetch| will be hanging until it receives |Content-Type| header - // from server. - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.write('hello '); - }); - // send request and wait for server response - const [pageResponse] = await Promise.all([ - page.waitForEvent('response'), - page.evaluate(() => fetch('./get', { method: 'GET' })), - ]); - - const finishPromise = pageResponse.finished().catch(e => e); - await page.close(); - const error = await finishPromise; - expect(error.message).toContain('closed'); -}); - it('should return json', async ({ page, server }) => { const response = await page.goto(server.PREFIX + '/simple.json'); expect(await response.json()).toEqual({ foo: 'bar' }); diff --git a/tests/page/page-request-continue.spec.ts b/tests/page/page-request-continue.spec.ts index a2a7e408626e1..7533b27a16667 100644 --- a/tests/page/page-request-continue.spec.ts +++ b/tests/page/page-request-continue.spec.ts @@ -150,33 +150,6 @@ it('should not allow changing protocol when overriding url', async ({ page, serv expect(error.message).toContain('New URL must have same protocol as overridden URL'); }); -it('should not throw when continuing while page is closing', async ({ page, server, isWebView2 }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - let done; - await page.route('**/*', async route => { - done = Promise.all([ - void route.continue(), - page.close(), - ]); - }); - await page.goto(server.EMPTY_PAGE).catch(e => e); - await done; -}); - -it('should not throw when continuing after page is closed', async ({ page, server, isWebView2 }) => { - it.skip(isWebView2, 'Page.close() is not supported in WebView2'); - - let done; - await page.route('**/*', async route => { - await page.close(); - done = route.continue(); - }); - const error = await page.goto(server.EMPTY_PAGE).catch(e => e); - await done; - expect(error).toBeInstanceOf(Error); -}); - it('should not throw if request was cancelled by the page', async ({ page, server, browserName }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28490' }); let interceptCallback; From b1a1e11ad8254af1c3ece5387bb3d15b6d698400 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 23 Jun 2025 22:43:15 +0200 Subject: [PATCH 69/71] chore: delete utils/doclint/generateFullConfigDoc.js (#36413) --- utils/doclint/generateFullConfigDoc.js | 121 ------------------------- 1 file changed, 121 deletions(-) delete mode 100644 utils/doclint/generateFullConfigDoc.js diff --git a/utils/doclint/generateFullConfigDoc.js b/utils/doclint/generateFullConfigDoc.js deleted file mode 100644 index 8c00f7f8a2767..0000000000000 --- a/utils/doclint/generateFullConfigDoc.js +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// @ts-check - -const path = require('path'); -const fs = require('fs'); -const PROJECT_DIR = path.join(__dirname, '..', '..'); - -function generateFullConfigClass(fromClassName, toClassName, allowList) { - const allowedNames = new Set(allowList); - - const content = fs.readFileSync(path.join(PROJECT_DIR, `docs/src/test-api/class-${fromClassName.toLowerCase()}.md`)).toString(); - let sections = content.split('\n## '); - sections = filterAllowedSections(sections, allowedNames); - if (allowedNames.size) - console.log(`Undocumented properties for ${fromClassName}:\n ${[...allowedNames].join('\n ')}`); - sections = changeClassName(sections, fromClassName, toClassName); - sections = replacePropertyDescriptions(sections, fromClassName); - const fullconfig = sections.join('\n## '); - fs.writeFileSync(path.join(PROJECT_DIR, `docs/src/test-api/class-${toClassName.toLowerCase()}.md`), fullconfig); -} - -function propertyNameFromSection(section) { - section = section.split('\n')[0]; - const match = /\.(\w+)/.exec(section); - if (!match) - return null; - return match[1]; -} - -function filterAllowedSections(sections, allowedNames) { - return sections.filter(section => { - section = section.split('\n')[0]; - const name = propertyNameFromSection(section); - if (!name) - return true; - return allowedNames.delete(name); - }); -} - -function changeClassName(sections, from, to) { - return sections.map(section => { - const lines = section.split('\n'); - lines[0] = lines[0].replace(from, to); - return lines.join('\n'); - }); -} - -function replacePropertyDescriptions(sections, configClassName) { - return sections.map(section => { - const parts = section.split('\n\n'); - section = parts[0]; - const name = propertyNameFromSection(section); - if (!name) - return `${section}\n`; - return `${section}\n\nSee [\`property: ${configClassName}.${name}\`].\n`; - }); -} - -function generateFullConfig() { - generateFullConfigClass('TestConfig', 'FullConfig', [ - 'forbidOnly', - 'fullyParallel', - 'globalSetup', - 'globalTeardown', - 'globalTimeout', - 'grep', - 'grepInvert', - 'maxFailures', - 'metadata', - 'version', - 'preserveOutput', - 'projects', - 'reporter', - 'reportSlowTests', - 'rootDir', - 'quiet', - 'shard', - 'updateSnapshots', - 'workers', - 'webServer', - 'configFile', - ]); -} - -function generateFullProject() { - generateFullConfigClass('TestProject', 'FullProject', [ - 'grep', - 'grepInvert', - 'metadata', - 'name', - 'dependencies', - 'snapshotDir', - 'outputDir', - 'repeatEach', - 'retries', - 'teardown', - 'testDir', - 'testIgnore', - 'testMatch', - 'timeout', - 'use', - ]); -} - -generateFullConfig(); -generateFullProject(); From 896cb8536e7ca561fd9a6179cbb9643cccc848bc Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 23 Jun 2025 22:52:21 +0200 Subject: [PATCH 70/71] chore: fix Cannot find module '@testIsomorphic/types' in recorder (#36414) --- packages/recorder/tsconfig.json | 1 + packages/recorder/tsconfig.node.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/recorder/tsconfig.json b/packages/recorder/tsconfig.json index 166ee1a010ca1..4defa85f64dd4 100644 --- a/packages/recorder/tsconfig.json +++ b/packages/recorder/tsconfig.json @@ -20,6 +20,7 @@ "@isomorphic/*": ["../playwright-core/src/utils/isomorphic/*"], "@protocol/*": ["../protocol/src/*"], "@recorder/*": ["../recorder/src/*"], + "@testIsomorphic/*": ["../playwright/src/isomorphic/*"], "@web/*": ["../web/src/*"], } }, diff --git a/packages/recorder/tsconfig.node.json b/packages/recorder/tsconfig.node.json index e993792cb12c9..a336f895aab52 100644 --- a/packages/recorder/tsconfig.node.json +++ b/packages/recorder/tsconfig.node.json @@ -2,7 +2,8 @@ "compilerOptions": { "composite": true, "module": "esnext", - "moduleResolution": "node" + "moduleResolution": "node", + "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } From 07e981f196ca2c9e19289c53aba7fd0e50c56eee Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 24 Jun 2025 13:55:32 +0200 Subject: [PATCH 71/71] fix: get rid of url.parse in network code --- .../src/server/utils/network.ts | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/playwright-core/src/server/utils/network.ts b/packages/playwright-core/src/server/utils/network.ts index d12b72a1c389e..6054f60878618 100644 --- a/packages/playwright-core/src/server/utils/network.ts +++ b/packages/playwright-core/src/server/utils/network.ts @@ -39,9 +39,8 @@ export type HTTPRequestParams = { export const NET_DEFAULT_TIMEOUT = 30_000; export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.IncomingMessage) => void, onError: (error: Error) => void): { cancel(error: Error | undefined): void } { - const parsedUrl = url.parse(params.url); - let options: https.RequestOptions = { - ...parsedUrl, + const parsedUrl = new URL(params.url); + const options: https.RequestOptions = { agent: parsedUrl.protocol === 'https:' ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent, method: params.method || 'GET', headers: params.headers, @@ -51,19 +50,15 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco const proxyURL = getProxyForUrl(params.url); if (proxyURL) { - const parsedProxyURL = url.parse(proxyURL); + const parsedProxyURL = new URL(proxyURL); if (params.url.startsWith('http:')) { - options = { - path: parsedUrl.href, - host: parsedProxyURL.hostname, - port: parsedProxyURL.port, - headers: options.headers, - method: options.method - }; + parsedUrl.pathname = parsedUrl.href; + parsedUrl.host = parsedProxyURL.host; } else { - (parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:'; - - options.agent = new HttpsProxyAgent(parsedProxyURL); + options.agent = new HttpsProxyAgent({ + ...convertURLtoLegacyUrl(parsedProxyURL), + secureProxy: parsedProxyURL.protocol === 'https:', + }); options.rejectUnauthorized = false; } } @@ -81,8 +76,8 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco } }; const request = options.protocol === 'https:' ? - https.request(options, requestCallback) : - http.request(options, requestCallback); + https.request(parsedUrl, options, requestCallback) : + http.request(parsedUrl, options, requestCallback); request.on('error', onError); if (params.socketTimeout !== undefined) { request.setTimeout(params.socketTimeout, () => { @@ -137,23 +132,27 @@ export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) { if (!/^\w+:\/\//.test(proxyServer)) proxyServer = 'http://' + proxyServer; - const proxyOpts = url.parse(proxyServer); + const proxyOpts = new URL(proxyServer); if (proxyOpts.protocol?.startsWith('socks')) { return new SocksProxyAgent({ host: proxyOpts.hostname, port: proxyOpts.port || undefined, }); } - if (proxy.username) - proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`; + if (proxy.username) { + proxyOpts.username = proxy.username; + proxyOpts.password = proxy.password || ''; + } if (forUrl && ['ws:', 'wss:'].includes(forUrl.protocol)) { // Force CONNECT method for WebSockets. - return new HttpsProxyAgent(proxyOpts); + // TODO: switch to URL instance instead of legacy object once https-proxy-agent supports it. + return new HttpsProxyAgent(convertURLtoLegacyUrl(proxyOpts)); } // TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method. - return new HttpsProxyAgent(proxyOpts); + // TODO: switch to URL instance instead of legacy object once https-proxy-agent supports it. + return new HttpsProxyAgent(convertURLtoLegacyUrl(proxyOpts)); } export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; @@ -226,3 +225,20 @@ function decorateServer(server: net.Server) { return close.call(server, callback); }; } + +function convertURLtoLegacyUrl(url: URL): url.Url { + return { + auth: url.username ? url.username + ':' + url.password : null, + hash: url.hash || null, + host: url.hostname ? url.hostname + ':' + url.port : null, + hostname: url.hostname || null, + href: url.href, + path: url.pathname + url.search, + pathname: url.pathname, + protocol: url.protocol, + search: url.search || null, + slashes: true, + port: url.port || null, + query: url.search.slice(1) || null, + }; +}