diff --git a/README.md b/README.md index 50b0de8f45ccb..6398a385cd36e 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-120.0.6099.5-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-119.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-120.0.6099.18-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-119.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) ## [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 120.0.6099.5 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 120.0.6099.18 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 17.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 119.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index 7aca02a33ced4..164108ad1cf12 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -30,7 +30,7 @@ test('pass', async ({ page }) => { }); ``` -See the documentation [for a full example](./test-configuration.md#add-custom-matchers-using-expectextend). +See the documentation [for a full example](./test-assertions.md#add-custom-matchers-using-expectextend). ### Merge test fixtures diff --git a/docs/src/test-assertions-js.md b/docs/src/test-assertions-js.md index cb357ac05629a..d05b7a750a8a1 100644 --- a/docs/src/test-assertions-js.md +++ b/docs/src/test-assertions-js.md @@ -94,7 +94,7 @@ Prefer [auto-retrying](#auto-retrying-assertions) assertions whenever possible. | [`method: GenericAssertions.stringContaining`] | String contains a substring | | [`method: GenericAssertions.stringMatching`] | String matches a regular expression | -## Negating Matchers +## Negating matchers In general, we can expect the opposite to be true by adding a `.not` to the front of the matchers: @@ -104,7 +104,7 @@ expect(value).not.toEqual(0); await expect(locator).not.toContainText('some text'); ``` -## Soft Assertions +## Soft assertions By default, failed assertion will terminate test execution. Playwright also supports *soft assertions*: failed soft assertions **do not** terminate test execution, @@ -134,7 +134,7 @@ expect(test.info().errors).toHaveLength(0); Note that soft assertions only work with Playwright test runner. -## Custom Expect Message +## Custom expect message You can specify a custom error message as a second argument to the `expect` function, for example: @@ -236,3 +236,86 @@ await expect(async () => { timeout: 60_000 }); ``` + +## Add custom matchers using expect.extend + +You can extend Playwright assertions by providing custom matchers. These matchers will be available on the `expect` object. + +In this example we add a custom `toHaveAmount` function. Custom matcher should return a `message` callback and a `pass` flag indicating whether the assertion passed. + +```js title="fixtures.ts" +import { expect as baseExpect } from '@playwright/test'; +import type { Page, Locator } from '@playwright/test'; + +export { test } from '@playwright/test'; + +export const expect = baseExpect.extend({ + async toHaveAmount(locator: Locator, expected: number, options?: { timeout?: number }) { + const assertionName = 'toHaveAmount'; + let pass: boolean; + let matcherResult: any; + try { + await baseExpect(locator).toHaveAttribute('data-amount', String(expected), options); + pass = true; + } catch (e: any) { + matcherResult = e.matcherResult; + pass = false; + } + + const message = pass + ? () => this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) + + '\n\n' + + `Locator: ${locator}\n` + + `Expected: ${this.isNot ? 'not' : ''}${this.utils.printExpected(expected)}\n` + + (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '') + : () => this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) + + '\n\n' + + `Locator: ${locator}\n` + + `Expected: ${this.utils.printExpected(expected)}\n` + + (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : ''); + + return { + message, + pass, + name: assertionName, + expected, + actual: matcherResult?.actual, + }; + }, +}); +``` + +Now we can use `toHaveAmount` in the test. + +```js title="example.spec.ts" +import { test, expect } from './fixtures'; + +test('amount', async () => { + await expect(page.locator('.cart')).toHaveAmount(4); +}); +``` + +:::note +Do not confuse Playwright's `expect` with the [`expect` library](https://jestjs.io/docs/expect). The latter is not fully integrated with Playwright test runner, so make sure to use Playwright's own `expect`. +::: + +### Combine custom matchers from multiple modules + +You can combine custom matchers from multiple files or modules. + +```js title="fixtures.ts" +import { mergeTests, mergeExpects } from '@playwright/test'; +import { test as dbTest, expect as dbExpect } from 'database-test-utils'; +import { test as a11yTest, expect as a11yExpect } from 'a11y-test-utils'; + +export const expect = mergeExpects(dbExpect, a11yExpect); +export const test = mergeTests(dbTest, a11yTest); +``` + +```js title="test.spec.ts" +import { test, expect } from './fixtures'; + +test('passes', async ({ database }) => { + await expect(database).toHaveDatabaseUser('admin'); +}); +``` diff --git a/docs/src/test-configuration-js.md b/docs/src/test-configuration-js.md index 6ebd29db0864b..ea598c8c8af61 100644 --- a/docs/src/test-configuration-js.md +++ b/docs/src/test-configuration-js.md @@ -150,86 +150,3 @@ export default defineConfig({ | [`method: PageAssertions.toHaveScreenshot#1`] | Configuration for the `expect(locator).toHaveScreeshot()` method. | | [`method: SnapshotAssertions.toMatchSnapshot#1`]| Configuration for the `expect(locator).toMatchSnapshot()` method.| - -### Add custom matchers using expect.extend - -You can extend Playwright assertions by providing custom matchers. These matchers will be available on the `expect` object. - -In this example we add a custom `toHaveAmount` function. Custom matcher should return a `message` callback and a `pass` flag indicating whether the assertion passed. - -```js title="fixtures.ts" -import { expect as baseExpect } from '@playwright/test'; -import type { Page, Locator } from '@playwright/test'; - -export { test } from '@playwright/test'; - -export const expect = baseExpect.extend({ - async toHaveAmount(locator: Locator, expected: number, options?: { timeout?: number }) { - const assertionName = 'toHaveAmount'; - let pass: boolean; - let matcherResult: any; - try { - await baseExpect(locator).toHaveAttribute('data-amount', String(expected), options); - pass = true; - } catch (e: any) { - matcherResult = e.matcherResult; - pass = false; - } - - const message = pass - ? () => this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) + - '\n\n' + - `Locator: ${locator}\n` + - `Expected: ${this.isNot ? 'not' : ''}${this.utils.printExpected(expected)}\n` + - (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '') - : () => this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) + - '\n\n' + - `Locator: ${locator}\n` + - `Expected: ${this.utils.printExpected(expected)}\n` + - (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : ''); - - return { - message, - pass, - name: assertionName, - expected, - actual: matcherResult?.actual, - }; - }, -}); -``` - -Now we can use `toHaveAmount` in the test. - -```js title="example.spec.ts" -import { test, expect } from './fixtures'; - -test('amount', async () => { - await expect(page.locator('.cart')).toHaveAmount(4); -}); -``` - -:::note -Do not confuse Playwright's `expect` with the [`expect` library](https://jestjs.io/docs/expect). The latter is not fully integrated with Playwright test runner, so make sure to use Playwright's own `expect`. -::: - -### Combine custom matchers from multiple modules - -You can combine custom matchers from multiple files or modules. - -```js title="fixtures.ts" -import { mergeTests, mergeExpects } from '@playwright/test'; -import { test as dbTest, expect as dbExpect } from 'database-test-utils'; -import { test as a11yTest, expect as a11yExpect } from 'a11y-test-utils'; - -export const expect = mergeExpects(dbExpect, a11yExpect); -export const test = mergeTests(dbTest, a11yTest); -``` - -```js title="test.spec.ts" -import { test, expect } from './fixtures'; - -test('passes', async ({ database }) => { - await expect(database).toHaveDatabaseUser('admin'); -}); -``` diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 2978c5c966330..34b6f1374fab1 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,21 +3,21 @@ "browsers": [ { "name": "chromium", - "revision": "1089", + "revision": "1090", "installByDefault": true, - "browserVersion": "120.0.6099.5" + "browserVersion": "120.0.6099.18" }, { "name": "chromium-with-symbols", - "revision": "1089", + "revision": "1090", "installByDefault": false, - "browserVersion": "120.0.6099.5" + "browserVersion": "120.0.6099.18" }, { "name": "chromium-tip-of-tree", - "revision": "1166", + "revision": "1167", "installByDefault": false, - "browserVersion": "121.0.6113.0" + "browserVersion": "121.0.6117.0" }, { "name": "firefox", @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "1932", + "revision": "1942", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index 6c7a9e44ae59f..651bcbe3e9e19 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -274,8 +274,10 @@ export class AndroidDevice extends ChannelOwner i async launchBrowser(options: types.BrowserContextOptions & { pkg?: string } = {}): Promise { const contextOptions = await prepareBrowserContextParams(options); - const { context } = await this._channel.launchBrowser(contextOptions); - return BrowserContext.from(context) as BrowserContext; + const result = await this._channel.launchBrowser(contextOptions); + const context = BrowserContext.from(result.context) as BrowserContext; + context._setOptions(contextOptions, {}); + return context; } async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise { diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 0e8701f905fa3..0430e3c0bc49a 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -42,6 +42,7 @@ import { Keyboard, Mouse, Touchscreen } from './input'; import { assertMaxArguments, JSHandle, parseResult, serializeArgument } from './jsHandle'; import type { FrameLocator, Locator, LocatorOptions } from './locator'; import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; +import { trimStringWithEllipsis } from '../utils/isomorphic/stringUtils'; import { type RouteHandlerCallback, type Request, Response, Route, RouteHandler, validateHeaders, WebSocket } from './network'; import type { FilePayload, Headers, LifecycleEvent, SelectOption, SelectOptionOptions, Size, URLMatch, WaitForEventOptions, WaitForFunctionOptions } from './types'; import { Video } from './video'; @@ -751,15 +752,9 @@ export class BindingCall extends ChannelOwner { } } -function trimEnd(s: string): string { - if (s.length > 50) - s = s.substring(0, 50) + '\u2026'; - return s; -} - function trimUrl(param: any): string | undefined { if (isRegExp(param)) - return `/${trimEnd(param.source)}/${param.flags}`; + return `/${trimStringWithEllipsis(param.source, 50)}/${param.flags}`; if (isString(param)) - return `"${trimEnd(param)}"`; + return `"${trimStringWithEllipsis(param, 50)}"`; } diff --git a/packages/playwright-core/src/server/chromium/protocol.d.ts b/packages/playwright-core/src/server/chromium/protocol.d.ts index 8b390444aaa82..e6115a8e7f961 100644 --- a/packages/playwright-core/src/server/chromium/protocol.d.ts +++ b/packages/playwright-core/src/server/chromium/protocol.d.ts @@ -878,7 +878,7 @@ Should be updated alongside RequestIdTokenStatus in third_party/blink/public/mojom/devtools/inspector_issue.mojom to include all cases except for success. */ - export type FederatedAuthRequestIssueReason = "ShouldEmbargo"|"TooManyRequests"|"WellKnownHttpNotFound"|"WellKnownNoResponse"|"WellKnownInvalidResponse"|"WellKnownListEmpty"|"WellKnownInvalidContentType"|"ConfigNotInWellKnown"|"WellKnownTooBig"|"ConfigHttpNotFound"|"ConfigNoResponse"|"ConfigInvalidResponse"|"ConfigInvalidContentType"|"ClientMetadataHttpNotFound"|"ClientMetadataNoResponse"|"ClientMetadataInvalidResponse"|"ClientMetadataInvalidContentType"|"DisabledInSettings"|"ErrorFetchingSignin"|"InvalidSigninResponse"|"AccountsHttpNotFound"|"AccountsNoResponse"|"AccountsInvalidResponse"|"AccountsListEmpty"|"AccountsInvalidContentType"|"IdTokenHttpNotFound"|"IdTokenNoResponse"|"IdTokenInvalidResponse"|"IdTokenInvalidRequest"|"IdTokenInvalidContentType"|"ErrorIdToken"|"Canceled"|"RpPageNotVisible"|"SilentMediationFailure"|"ThirdPartyCookiesBlocked"|"NotSignedInWithIdp"; + export type FederatedAuthRequestIssueReason = "ShouldEmbargo"|"TooManyRequests"|"WellKnownHttpNotFound"|"WellKnownNoResponse"|"WellKnownInvalidResponse"|"WellKnownListEmpty"|"WellKnownInvalidContentType"|"ConfigNotInWellKnown"|"WellKnownTooBig"|"ConfigHttpNotFound"|"ConfigNoResponse"|"ConfigInvalidResponse"|"ConfigInvalidContentType"|"ClientMetadataHttpNotFound"|"ClientMetadataNoResponse"|"ClientMetadataInvalidResponse"|"ClientMetadataInvalidContentType"|"DisabledInSettings"|"ErrorFetchingSignin"|"InvalidSigninResponse"|"AccountsHttpNotFound"|"AccountsNoResponse"|"AccountsInvalidResponse"|"AccountsListEmpty"|"AccountsInvalidContentType"|"IdTokenHttpNotFound"|"IdTokenNoResponse"|"IdTokenInvalidResponse"|"IdTokenIdpErrorResponse"|"IdTokenCrossSiteIdpErrorResponse"|"IdTokenInvalidRequest"|"IdTokenInvalidContentType"|"ErrorIdToken"|"Canceled"|"RpPageNotVisible"|"SilentMediationFailure"|"ThirdPartyCookiesBlocked"|"NotSignedInWithIdp"; export interface FederatedAuthUserInfoRequestIssueDetails { federatedAuthUserInfoRequestIssueReason: FederatedAuthUserInfoRequestIssueReason; } diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index bfc39f856c990..1a7a6c1fa161e 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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -978,7 +978,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -989,7 +989,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1000,7 +1000,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1011,7 +1011,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1022,7 +1022,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1033,7 +1033,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1044,7 +1044,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/120.0.6099.5 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1055,7 +1055,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/120.0.6099.5 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1066,7 +1066,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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1077,7 +1077,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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1088,7 +1088,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1099,7 +1099,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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1110,7 +1110,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1121,7 +1121,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1132,7 +1132,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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1143,7 +1143,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/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1154,7 +1154,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1165,7 +1165,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1176,7 +1176,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/120.0.6099.5 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1187,7 +1187,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/120.0.6099.5 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1242,7 +1242,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1253,7 +1253,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1264,7 +1264,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1275,7 +1275,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1286,7 +1286,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1297,7 +1297,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/120.0.6099.5 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/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1308,7 +1308,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1319,7 +1319,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1330,7 +1330,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1345,7 +1345,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1360,7 +1360,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1375,7 +1375,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1390,7 +1390,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1405,7 +1405,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1420,7 +1420,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1431,7 +1431,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1442,7 +1442,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1457,7 +1457,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Safari/537.36 Edg/120.0.6099.5", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36 Edg/120.0.6099.18", "screen": { "width": 1792, "height": 1120 @@ -1502,7 +1502,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1517,7 +1517,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.5 Safari/537.36 Edg/120.0.6099.5", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.18 Safari/537.36 Edg/120.0.6099.18", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/src/server/injected/highlight.css.ts b/packages/playwright-core/src/server/injected/highlight.css similarity index 89% rename from packages/playwright-core/src/server/injected/highlight.css.ts rename to packages/playwright-core/src/server/injected/highlight.css index 18955755ba01e..0d0a74e7b3155 100644 --- a/packages/playwright-core/src/server/injected/highlight.css.ts +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -14,16 +14,18 @@ * limitations under the License. */ -export const highlightCSS = ` +:host { + font-size: 13px; + font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif; + color: #333; +} + x-pw-tooltip { backdrop-filter: blur(5px); background-color: white; - color: #222; border-radius: 6px; box-shadow: 0 0.5rem 1.2rem rgba(0,0,0,.3); display: none; - font-family: 'Dank Mono', 'Operator Mono', Inconsolata, 'Fira Mono', - 'SF Mono', Monaco, 'Droid Sans Mono', 'Source Code Pro', monospace; font-size: 12.8px; font-weight: normal; left: 0; @@ -31,11 +33,27 @@ x-pw-tooltip { max-width: 600px; position: absolute; top: 0; + padding: 4px; } -x-pw-tooltip-body { - align-items: center; - padding: 3.2px 5.12px 3.2px; + +x-pw-dialog { + background-color: white; + pointer-events: auto; + border-radius: 6px; + box-shadow: 0 0.5rem 1.2rem rgba(0,0,0,.3); + display: flex; + flex-direction: column; + position: absolute; + min-width: 500px; + min-height: 200px; } + +x-pw-dialog-body { + display: flex; + flex-direction: column; + flex: auto; +} + x-pw-highlight { position: absolute; top: 0; @@ -43,6 +61,7 @@ x-pw-highlight { width: 0; height: 0; } + x-pw-action-point { position: absolute; width: 20px; @@ -52,20 +71,24 @@ x-pw-action-point { margin: -10px 0 0 -10px; z-index: 2; } + x-pw-separator { height: 1px; margin: 6px 9px; background: rgb(148 148 148 / 90%); } + x-pw-tool-gripper { height: 28px; width: 24px; margin: 2px 0; cursor: grab; } + x-pw-tool-gripper:active { cursor: grabbing; } + x-pw-tool-gripper > x-div { width: 100%; height: 100%; @@ -79,11 +102,20 @@ x-pw-tool-gripper > x-div { mask-image: url("data:image/svg+xml;utf8,"); background-color: #555555; } + +x-pw-tool-label { + display: flex; + align-items: center; + margin-left: 10px; + user-select: none; +} + x-pw-tools-list { display: flex; width: 100%; border-bottom: 1px solid #dddddd; } + x-pw-tool-item { pointer-events: auto; cursor: pointer; @@ -91,9 +123,11 @@ x-pw-tool-item { width: 28px; border-radius: 3px; } + x-pw-tool-item:not(.disabled):hover { background-color: hsl(0, 0%, 86%); } + x-pw-tool-item > x-div { width: 100%; height: 100%; @@ -105,45 +139,52 @@ x-pw-tool-item > x-div { mask-size: 16px; background-color: #3a3a3a; } + x-pw-tool-item.disabled > x-div { background-color: rgba(97, 97, 97, 0.5); cursor: default; } + x-pw-tool-item.active > x-div { background-color: #006ab1; } + x-pw-tool-item.record.active > x-div { background-color: #a1260d; } + x-pw-tool-item.accept > x-div { background-color: #388a34; } -x-pw-tool-item.cancel > x-div { - background-color: #e51400; -} + x-pw-tool-item.record > x-div { /* codicon: circle-large-filled */ -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); } + x-pw-tool-item.pick-locator > x-div { /* codicon: inspect */ -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); } + x-pw-tool-item.assert > x-div { /* codicon: check-all */ -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); } + x-pw-tool-item.accept > x-div { -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); } + x-pw-tool-item.cancel > x-div { -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); } + x-pw-overlay { position: absolute; top: 0; @@ -152,21 +193,52 @@ x-pw-overlay { background: transparent; pointer-events: auto; } + x-pw-overlay x-pw-tools-list { background-color: #ffffffdd; box-shadow: rgba(0, 0, 0, 0.1) 0px 5px 5px; border-radius: 3px; border-bottom: none; } + x-pw-overlay x-pw-tool-item { margin: 2px; } + +input.locator-editor { + display: flex; + padding: 10px; + flex: none; + border: none; + border-bottom: 1px solid #dddddd; +} + +input.locator-editor:focus, +textarea.text-editor:focus { + outline: none; +} + +textarea.text-editor { + font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif; + flex: auto; + border: none; + padding: 10px; + color: #333; +} + + x-div { display: block; } + +x-spacer { + flex: auto; +} + * { box-sizing: border-box; } + *[hidden] { display: none !important; -}`; +} diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index d6253d329222e..c629bf092bf3e 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -19,7 +19,7 @@ import type { ParsedSelector } from '../../utils/isomorphic/selectorParser'; import type { InjectedScript } from './injectedScript'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; -import { highlightCSS } from './highlight.css'; +import highlightCSS from './highlight.css?inline'; type HighlightEntry = { targetElement: Element, @@ -34,9 +34,6 @@ type HighlightEntry = { export type HighlightOptions = { tooltipText?: string; color?: string; - anchorGetter?: (element: Element) => DOMRect; - toolbar?: Element[]; - interactive?: boolean; }; export class Highlight { @@ -63,7 +60,12 @@ export class Highlight { this._glassPaneElement.style.pointerEvents = 'none'; this._glassPaneElement.style.display = 'flex'; this._glassPaneElement.style.backgroundColor = 'transparent'; - + for (const eventName of ['click', 'auxclick', 'dragstart', 'input', 'keydown', 'keyup', 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'mousemove', 'mouseleave', 'focus', 'scroll']) { + this._glassPaneElement.addEventListener(eventName, e => { + e.stopPropagation(); + e.stopImmediatePropagation(); + }); + } this._actionPointElement = document.createElement('x-pw-action-point'); this._actionPointElement.setAttribute('hidden', 'true'); this._glassPaneShadow = this._glassPaneElement.attachShadow({ mode: this._isUnderTest ? 'open' : 'closed' }); @@ -145,26 +147,12 @@ export class Highlight { let tooltipElement; if (options.tooltipText) { tooltipElement = this._injectedScript.document.createElement('x-pw-tooltip'); + this._glassPaneShadow.appendChild(tooltipElement); + const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : ''; + tooltipElement.textContent = options.tooltipText + suffix; tooltipElement.style.top = '0'; tooltipElement.style.left = '0'; tooltipElement.style.display = 'flex'; - tooltipElement.style.flexDirection = 'column'; - tooltipElement.style.alignItems = 'start'; - if (options.interactive) - tooltipElement.style.pointerEvents = 'auto'; - - if (options.toolbar) { - const toolbar = this._injectedScript.document.createElement('x-pw-tools-list'); - tooltipElement.appendChild(toolbar); - for (const toolbarElement of options.toolbar) - toolbar.appendChild(toolbarElement); - } - const bodyElement = this._injectedScript.document.createElement('x-pw-tooltip-body'); - tooltipElement.appendChild(bodyElement); - - this._glassPaneShadow.appendChild(tooltipElement); - const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : ''; - bodyElement.textContent = options.tooltipText + suffix; } this._highlightEntries.push({ targetElement: elements[i], tooltipElement, highlightElement, tooltipText: options.tooltipText }); } @@ -176,25 +164,7 @@ export class Highlight { continue; // Position tooltip, if any. - const tooltipWidth = entry.tooltipElement.offsetWidth; - const tooltipHeight = entry.tooltipElement.offsetHeight; - const totalWidth = this._glassPaneElement.offsetWidth; - const totalHeight = this._glassPaneElement.offsetHeight; - - const anchorBox = options.anchorGetter ? options.anchorGetter(entry.targetElement) : entry.box; - let anchorLeft = anchorBox.left; - if (anchorLeft + tooltipWidth > totalWidth - 5) - anchorLeft = totalWidth - tooltipWidth - 5; - let anchorTop = anchorBox.bottom + 5; - if (anchorTop + tooltipHeight > totalHeight - 5) { - // If can't fit below, either position above... - if (anchorBox.top > tooltipHeight + 5) { - anchorTop = anchorBox.top - tooltipHeight - 5; - } else { - // Or on top in case of large element - anchorTop = totalHeight - 5 - tooltipHeight; - } - } + const { anchorLeft, anchorTop } = this.tooltipPosition(entry.box, entry.tooltipElement); entry.tooltipTop = anchorTop; entry.tooltipLeft = anchorLeft; } @@ -219,6 +189,33 @@ export class Highlight { console.error('Highlight box for test: ' + JSON.stringify({ x: box.x, y: box.y, width: box.width, height: box.height })); // eslint-disable-line no-console } } + + firstBox(): DOMRect | undefined { + return this._highlightEntries[0]?.box; + } + + tooltipPosition(box: DOMRect, tooltipElement: HTMLElement) { + const tooltipWidth = tooltipElement.offsetWidth; + const tooltipHeight = tooltipElement.offsetHeight; + const totalWidth = this._glassPaneElement.offsetWidth; + const totalHeight = this._glassPaneElement.offsetHeight; + + let anchorLeft = box.left; + if (anchorLeft + tooltipWidth > totalWidth - 5) + anchorLeft = totalWidth - tooltipWidth - 5; + let anchorTop = box.bottom + 5; + if (anchorTop + tooltipHeight > totalHeight - 5) { + // If can't fit below, either position above... + if (box.top > tooltipHeight + 5) { + anchorTop = box.top - tooltipHeight - 5; + } else { + // Or on top in case of large element + anchorTop = totalHeight - 5 - tooltipHeight; + } + } + return { anchorLeft, anchorTop }; + } + private _highlightIsUpToDate(elements: Element[], tooltipText: string | undefined): boolean { if (elements.length !== this._highlightEntries.length) return false; diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index d19603b827097..13a35cdb539ff 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -33,7 +33,7 @@ import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName } fr import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; -import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; +import { normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; type Predicate = (progress: InjectedScriptProgress) => T | symbol; @@ -67,6 +67,10 @@ export type HitTargetInterceptionResult = { stop: () => 'done' | { hitTargetDescription: string }; }; +interface WebKitLegacyDeviceOrientationEvent extends DeviceOrientationEvent { + readonly initDeviceOrientationEvent: (type: string, bubbles: boolean, cancelable: boolean, alpha: number, beta: number, gamma: number, absolute: boolean) => void; +} + export class InjectedScript { private _engines: Map; _evaluator: SelectorEvaluatorImpl; @@ -1036,6 +1040,15 @@ export class InjectedScript { case 'focus': event = new FocusEvent(type, eventInit); break; case 'drag': event = new DragEvent(type, eventInit); break; case 'wheel': event = new WheelEvent(type, eventInit); break; + case 'deviceorientation': + try { + event = new DeviceOrientationEvent(type, eventInit); + } catch { + const { bubbles, cancelable, alpha, beta, gamma, absolute } = eventInit as {bubbles: boolean, cancelable: boolean, alpha: number, beta: number, gamma: number, absolute: boolean}; + event = this.document.createEvent('DeviceOrientationEvent') as WebKitLegacyDeviceOrientationEvent; + event.initDeviceOrientationEvent(type, bubbles, cancelable, alpha, beta, gamma, absolute); + } + break; default: event = new Event(type, eventInit); break; } node.dispatchEvent(event); @@ -1059,9 +1072,7 @@ export class InjectedScript { attrs.push(` ${name}="${value}"`); } attrs.sort((a, b) => a.length - b.length); - let attrText = attrs.join(''); - if (attrText.length > 50) - attrText = attrText.substring(0, 49) + '\u2026'; + const attrText = trimStringWithEllipsis(attrs.join(''), 50); if (autoClosingTags.has(element.nodeName)) return oneLine(`<${element.nodeName.toLowerCase()}${attrText}/>`); @@ -1072,10 +1083,8 @@ export class InjectedScript { for (let i = 0; i < children.length; i++) onlyText = onlyText && children[i].nodeType === Node.TEXT_NODE; } - let text = onlyText ? (element.textContent || '') : (children.length ? '\u2026' : ''); - if (text.length > 50) - text = text.substring(0, 49) + '\u2026'; - return oneLine(`<${element.nodeName.toLowerCase()}${attrText}>${text}`); + const text = onlyText ? (element.textContent || '') : (children.length ? '\u2026' : ''); + return oneLine(`<${element.nodeName.toLowerCase()}${attrText}>${trimStringWithEllipsis(text, 50)}`); } strictModeViolationError(selector: ParsedSelector, matches: Element[]): Error { @@ -1371,7 +1380,7 @@ function oneLine(s: string): string { return s.replace(/\n/g, '↡').replace(/\t/g, '⇆'); } -const eventType = new Map([ +const eventType = new Map([ ['auxclick', 'mouse'], ['click', 'mouse'], ['dblclick', 'mouse'], @@ -1419,6 +1428,9 @@ const eventType = new Map; @@ -442,217 +444,187 @@ class RecordActionTool implements RecorderTool { class TextAssertionTool implements RecorderTool { private _hoverHighlight: HighlightModel | null = null; - private _selectionHighlight: HighlightModel | null = null; - private _selectionText: { selectedText: string, fullText: string } | null = null; - private _inputHighlight: HighlightModel | null = null; + private _action: actions.AssertAction | null = null; + private _dialogElement: HTMLElement | null = null; private _acceptButton: HTMLElement; private _cancelButton: HTMLElement; + private _keyboardListener: ((event: KeyboardEvent) => void) | undefined; constructor(private _recorder: Recorder) { this._acceptButton = this._recorder.document.createElement('x-pw-tool-item'); + this._acceptButton.title = 'Accept'; this._acceptButton.classList.add('accept'); this._acceptButton.appendChild(this._recorder.document.createElement('x-div')); - this._acceptButton.addEventListener('click', () => this._commitAction()); + this._acceptButton.addEventListener('click', () => this._commit()); this._cancelButton = this._recorder.document.createElement('x-pw-tool-item'); + this._cancelButton.title = 'Close'; this._cancelButton.classList.add('cancel'); this._cancelButton.appendChild(this._recorder.document.createElement('x-div')); - this._cancelButton.addEventListener('click', () => this._cancelAction()); + this._cancelButton.addEventListener('click', () => this._closeDialog()); } cursor() { - return 'text'; + return 'pointer'; } cleanup() { + this._closeDialog(); this._hoverHighlight = null; - this._selectionHighlight = null; - this._selectionText = null; - this._inputHighlight = null; } onClick(event: MouseEvent) { + if (!this._dialogElement) + this._showDialog(); consumeEvent(event); - const selection = this._recorder.document.getSelection(); - if (event.detail === 1 && selection && !selection.toString() && !this._inputHighlight) { - const target = this._recorder.deepEventTarget(event); - selection.selectAllChildren(target); - this._updateSelectionHighlight(); - } - } - - onMouseDown(event: MouseEvent) { - const target = this._recorder.deepEventTarget(event); - if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) { - this._recorder.injectedScript.window.getSelection()?.empty(); - this._inputHighlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }); - this._showHighlight(true); - consumeEvent(event); - return; - } - - this._inputHighlight = null; - this._hoverHighlight = null; - this._updateSelectionHighlight(); - } - - onMouseUp(event: MouseEvent) { - this._updateSelectionHighlight(); } onMouseMove(event: MouseEvent) { - const selection = this._recorder.document.getSelection(); - if (selection && selection.toString()) { - this._updateSelectionHighlight(); - return; - } - if (this._inputHighlight || event.buttons) + if (this._dialogElement) return; const target = this._recorder.deepEventTarget(event); if (this._hoverHighlight?.elements[0] === target) return; - this._hoverHighlight = elementText(new Map(), target).full ? { elements: [target], selector: '' } : null; + this._hoverHighlight = target.nodeName === 'INPUT' || target.nodeName === 'TEXTAREA' || elementText(new Map(), target).full ? { elements: [target], selector: '' } : null; this._recorder.updateHighlight(this._hoverHighlight, true, { color: '#8acae480' }); } - onDragStart(event: DragEvent) { - consumeEvent(event); - } - onKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') { - const selection = this._recorder.document.getSelection(); - if (selection && selection.toString()) - this._resetSelectionAndHighlight(); - else - this._recorder.delegate.setMode?.('recording'); - consumeEvent(event); - return; - } - - if (event.key === 'Enter') { - this._commitAction(); - consumeEvent(event); - return; - } - - // Only allow keys that control text selection. - if (!['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Shift', 'Control', 'Meta', 'Alt', 'AltGraph'].includes(event.key)) { - consumeEvent(event); - return; - } - } - - onKeyUp(event: KeyboardEvent) { + if (event.key === 'Escape') + this._recorder.delegate.setMode?.('recording'); consumeEvent(event); } - onScroll(event: Event) { - this._hoverHighlight = null; - this._showHighlight(false); - } - - private _generateAction(): actions.Action | null { - if (this._inputHighlight) { - const target = this._inputHighlight.elements[0] as HTMLInputElement; - if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes(target.type.toLowerCase())) { + private _generateAction(): actions.AssertAction | null { + const target = this._hoverHighlight?.elements[0]; + if (!target) + return null; + if (target.nodeName === 'INPUT' || target.nodeName === 'TEXTAREA') { + const { selector } = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }); + if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes((target as HTMLInputElement).type.toLowerCase())) { return { name: 'assertChecked', - selector: this._inputHighlight.selector, + selector, signals: [], // Interestingly, inputElement.checked is reversed inside this event handler. - checked: !(target as HTMLInputElement).checked, + checked: (target as HTMLInputElement).checked, }; } else { return { name: 'assertValue', - selector: this._inputHighlight.selector, + selector, signals: [], - value: target.value, + value: (target as HTMLInputElement).value, }; } - } else if (this._selectionText && this._selectionHighlight) { + } else { + const { selector } = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }); return { name: 'assertText', - selector: this._selectionHighlight.selector, + selector, signals: [], - text: this._selectionText.selectedText, - substring: this._selectionText.fullText !== this._selectionText.selectedText, + text: target.textContent!, + substring: true, }; } - return null; } - private _generateActionPreview() { - const action = this._generateAction(); - // TODO: support other languages, maybe unify with code generator? + private _renderValue(action: actions.Action) { if (action?.name === 'assertText') - return `expect(${asLocator(this._recorder.state.language, action.selector)}).${action.substring ? 'toContainText' : 'toHaveText'}(${escapeWithQuotes(action.text)})`; + return normalizeWhiteSpace(action.text); if (action?.name === 'assertChecked') - return `expect(${asLocator(this._recorder.state.language, action.selector)})${action.checked ? '' : '.not'}.toBeChecked()`; - if (action?.name === 'assertValue') { - const assertion = action.value ? `toHaveValue(${escapeWithQuotes(action.value)})` : `toBeEmpty()`; - return `expect(${asLocator(this._recorder.state.language, action.selector)}).${assertion}`; - } + return String(action.checked); + if (action?.name === 'assertValue') + return action.value; return ''; } - private _commitAction() { - const action = this._generateAction(); - if (action) { - this._resetSelectionAndHighlight(); - this._recorder.delegate.recordAction?.(action); - this._recorder.delegate.setMode?.('recording'); - } + private _commit() { + if (!this._action || !this._dialogElement) + return; + this._closeDialog(); + this._recorder.delegate.recordAction?.(this._action); + this._recorder.delegate.setMode?.('recording'); } - private _cancelAction() { - this._resetSelectionAndHighlight(); - } + private _showDialog() { + const target = this._hoverHighlight?.elements[0]; + if (!target) + return; + this._action = this._generateAction(); + if (!this._action) + return; - private _resetSelectionAndHighlight() { - this._selectionHighlight = null; - this._selectionText = null; - this._inputHighlight = null; - this._recorder.injectedScript.window.getSelection()?.empty(); - this._recorder.updateHighlight(null, false); + this._dialogElement = this._recorder.document.createElement('x-pw-dialog'); + this._keyboardListener = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this._closeDialog(); + return; + } + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + if (this._dialogElement) + this._commit(); + return; + } + }; + this._recorder.document.addEventListener('keydown', this._keyboardListener, true); + const toolbarElement = this._recorder.document.createElement('x-pw-tools-list'); + toolbarElement.appendChild(this._createLabel(this._action)); + toolbarElement.appendChild(this._recorder.document.createElement('x-spacer')); + toolbarElement.appendChild(this._acceptButton); + toolbarElement.appendChild(this._cancelButton); + + this._dialogElement.appendChild(toolbarElement); + const bodyElement = this._recorder.document.createElement('x-pw-dialog-body'); + const locatorElement = this._recorder.document.createElement('input'); + locatorElement.classList.add('locator-editor'); + locatorElement.value = asLocator(this._recorder.state.language, this._action.selector); + locatorElement.addEventListener('input', () => { + if (this._action) { + const selector = locatorOrSelectorAsSelector(this._recorder.state.language, locatorElement.value, this._recorder.state.testIdAttributeName); + const model: HighlightModel = { + selector, + elements: this._recorder.injectedScript.querySelectorAll(parseSelector(selector), this._recorder.document), + }; + this._action.selector = selector; + this._recorder.updateHighlight(model, true); + } + }); + const textElement = this._recorder.document.createElement('textarea'); + textElement.value = this._renderValue(this._action); + textElement.classList.add('text-editor'); + + textElement.addEventListener('input', () => { + if (this._action?.name === 'assertText') + this._action.text = normalizeWhiteSpace(elementText(new Map(), textElement).full); + if (this._action?.name === 'assertChecked') + this._action.checked = textElement.value === 'true'; + if (this._action?.name === 'assertValue') + this._action.value = textElement.value; + }); + + bodyElement.appendChild(locatorElement); + bodyElement.appendChild(textElement); + this._dialogElement.appendChild(bodyElement); + this._recorder.highlight.appendChild(this._dialogElement); + const position = this._recorder.highlight.tooltipPosition(this._recorder.highlight.firstBox()!, this._dialogElement); + this._dialogElement.style.top = position.anchorTop + 'px'; + this._dialogElement.style.left = position.anchorLeft + 'px'; + textElement.focus(); } - private _updateSelectionHighlight() { - if (this._inputHighlight) - return; - const selection = this._recorder.document.getSelection(); - const selectedText = normalizeWhiteSpace(selection?.toString() || ''); - let highlight: HighlightModel | null = null; - if (selection && selection.focusNode && selection.anchorNode && selectedText) { - const focusElement = enclosingElement(selection.focusNode); - let lcaElement = focusElement ? enclosingElement(selection.anchorNode) : undefined; - while (lcaElement && !isInsideScope(lcaElement, focusElement)) - lcaElement = parentElementOrShadowHost(lcaElement); - highlight = lcaElement ? generateSelector(this._recorder.injectedScript, lcaElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }) : null; - } - const fullText = highlight ? normalizeWhiteSpace(elementText(new Map(), highlight.elements[0]).full) : ''; - const selectionText = highlight ? { selectedText, fullText } : null; - if (highlight?.selector === this._selectionHighlight?.selector && this._selectionText?.fullText === selectionText?.fullText && this._selectionText?.selectedText === selectionText?.selectedText) - return; - this._selectionHighlight = highlight; - this._selectionText = selectionText; - this._showHighlight(true); + private _createLabel(action: actions.AssertAction) { + const labelElement = this._recorder.document.createElement('x-pw-tool-label'); + labelElement.textContent = action.name === 'assertText' ? 'Assert text' : action.name === 'assertValue' ? 'Assert value' : 'Assert checked'; + return labelElement; } - private _showHighlight(userGesture: boolean) { - const options: HighlightOptions = { - color: '#6fdcbd38', - tooltipText: this._generateActionPreview(), - toolbar: [this._acceptButton, this._cancelButton], - interactive: true, - }; - if (this._inputHighlight) { - this._recorder.updateHighlight(this._inputHighlight, userGesture, options); - } else { - options.anchorGetter = (e: Element) => this._recorder.document.getSelection()?.getRangeAt(0)?.getBoundingClientRect() || e.getBoundingClientRect(); - this._recorder.updateHighlight(this._selectionHighlight, userGesture, options); - } + private _closeDialog() { + if (!this._dialogElement) + return; + this._dialogElement.remove(); + this._recorder.document.removeEventListener('keydown', this._keyboardListener!); + this._dialogElement = null; } } diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 51ff0c2560c8f..e353ad1556303 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace, quoteCSSAttributeValue } from '../../utils/isomorphic/stringUtils'; +import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace, quoteCSSAttributeValue, trimString } from '../../utils/isomorphic/stringUtils'; import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils'; import type { InjectedScript } from './injectedScript'; import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils'; @@ -276,7 +276,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i } const fullText = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full); - const text = fullText.substring(0, 80); + const text = trimString(fullText, 80); if (text) { const escaped = escapeForTextSelector(text, false); if (isTargetNode) { diff --git a/packages/playwright-core/src/server/recorder/recorderActions.ts b/packages/playwright-core/src/server/recorder/recorderActions.ts index 5be43e9ea911c..3a4bbab3259a3 100644 --- a/packages/playwright-core/src/server/recorder/recorderActions.ts +++ b/packages/playwright-core/src/server/recorder/recorderActions.ts @@ -114,6 +114,7 @@ export type AssertCheckedAction = ActionBase & { }; export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction; +export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction; // Signals. diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index ee2258641ae99..d51ecf8d4111c 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -103,3 +103,16 @@ export function escapeForAttributeSelector(value: string | RegExp, exact: boolea // so we escape them differently. return `"${value.replace(/\\/g, '\\\\').replace(/["]/g, '\\"')}"${exact ? 's' : 'i'}`; } + +export function trimString(input: string, cap: number, suffix: string = ''): string { + if (input.length <= cap) + return input; + const chars = [...input]; + if (chars.length > cap) + return chars.slice(0, cap - suffix.length).join('') + suffix; + return chars.join(''); +} + +export function trimStringWithEllipsis(input: string, cap: number): string { + return trimString(input, cap, '\u2026'); +} \ No newline at end of file diff --git a/packages/playwright-core/types/protocol.d.ts b/packages/playwright-core/types/protocol.d.ts index 8b390444aaa82..e6115a8e7f961 100644 --- a/packages/playwright-core/types/protocol.d.ts +++ b/packages/playwright-core/types/protocol.d.ts @@ -878,7 +878,7 @@ Should be updated alongside RequestIdTokenStatus in third_party/blink/public/mojom/devtools/inspector_issue.mojom to include all cases except for success. */ - export type FederatedAuthRequestIssueReason = "ShouldEmbargo"|"TooManyRequests"|"WellKnownHttpNotFound"|"WellKnownNoResponse"|"WellKnownInvalidResponse"|"WellKnownListEmpty"|"WellKnownInvalidContentType"|"ConfigNotInWellKnown"|"WellKnownTooBig"|"ConfigHttpNotFound"|"ConfigNoResponse"|"ConfigInvalidResponse"|"ConfigInvalidContentType"|"ClientMetadataHttpNotFound"|"ClientMetadataNoResponse"|"ClientMetadataInvalidResponse"|"ClientMetadataInvalidContentType"|"DisabledInSettings"|"ErrorFetchingSignin"|"InvalidSigninResponse"|"AccountsHttpNotFound"|"AccountsNoResponse"|"AccountsInvalidResponse"|"AccountsListEmpty"|"AccountsInvalidContentType"|"IdTokenHttpNotFound"|"IdTokenNoResponse"|"IdTokenInvalidResponse"|"IdTokenInvalidRequest"|"IdTokenInvalidContentType"|"ErrorIdToken"|"Canceled"|"RpPageNotVisible"|"SilentMediationFailure"|"ThirdPartyCookiesBlocked"|"NotSignedInWithIdp"; + export type FederatedAuthRequestIssueReason = "ShouldEmbargo"|"TooManyRequests"|"WellKnownHttpNotFound"|"WellKnownNoResponse"|"WellKnownInvalidResponse"|"WellKnownListEmpty"|"WellKnownInvalidContentType"|"ConfigNotInWellKnown"|"WellKnownTooBig"|"ConfigHttpNotFound"|"ConfigNoResponse"|"ConfigInvalidResponse"|"ConfigInvalidContentType"|"ClientMetadataHttpNotFound"|"ClientMetadataNoResponse"|"ClientMetadataInvalidResponse"|"ClientMetadataInvalidContentType"|"DisabledInSettings"|"ErrorFetchingSignin"|"InvalidSigninResponse"|"AccountsHttpNotFound"|"AccountsNoResponse"|"AccountsInvalidResponse"|"AccountsListEmpty"|"AccountsInvalidContentType"|"IdTokenHttpNotFound"|"IdTokenNoResponse"|"IdTokenInvalidResponse"|"IdTokenIdpErrorResponse"|"IdTokenCrossSiteIdpErrorResponse"|"IdTokenInvalidRequest"|"IdTokenInvalidContentType"|"ErrorIdToken"|"Canceled"|"RpPageNotVisible"|"SilentMediationFailure"|"ThirdPartyCookiesBlocked"|"NotSignedInWithIdp"; export interface FederatedAuthUserInfoRequestIssueDetails { federatedAuthUserInfoRequestIssueReason: FederatedAuthUserInfoRequestIssueReason; } diff --git a/tests/android/browser.spec.ts b/tests/android/browser.spec.ts index 2590ef791bb0f..d1b54206b98d2 100644 --- a/tests/android/browser.spec.ts +++ b/tests/android/browser.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import fs from 'fs'; import { androidTest as test, expect } from './androidTest'; test.afterAll(async ({ androidDevice }) => { @@ -155,3 +156,19 @@ test('should be able to pass context options', async ({ androidDevice, httpsServ expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: light)').matches)).toBe(false); await context.close(); }); + +test('should record har', async ({ androidDevice }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28015' }); + const harPath = test.info().outputPath('test.har'); + + const context = await androidDevice.launchBrowser({ + recordHar: { path: harPath } + }); + const [page] = context.pages(); + await page.goto('data:text/html,Hello'); + await page.waitForLoadState('domcontentloaded'); + await context.close(); + + const log = JSON.parse(fs.readFileSync(harPath).toString())['log']; + expect(log.pages[0].title).toBe('Hello'); +}); diff --git a/tests/assets/device-orientation.html b/tests/assets/device-orientation.html new file mode 100644 index 0000000000000..46be1f3f7aaea --- /dev/null +++ b/tests/assets/device-orientation.html @@ -0,0 +1,29 @@ + + + + + Device orientation test + + + + + + + diff --git a/tests/library/emulation-focus.spec.ts b/tests/library/emulation-focus.spec.ts index bc4bbb801cf3a..94f18927b4247 100644 --- a/tests/library/emulation-focus.spec.ts +++ b/tests/library/emulation-focus.spec.ts @@ -186,3 +186,36 @@ browserTest('should focus with more than one page/context', async ({ contextFact expect(await page1.evaluate(() => !!window['gotFocus'])).toBe(true); expect(await page2.evaluate(() => !!window['gotFocus'])).toBe(true); }); + +browserTest('should trigger hover state concurrently', async ({ browserType, browserName }) => { + browserTest.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27969' }); + browserTest.fixme(browserName === 'firefox'); + + const browser1 = await browserType.launch(); + const context1 = await browser1.newContext(); + const page1 = await context1.newPage(); + const page2 = await context1.newPage(); + const browser2 = await browserType.launch(); + const page3 = await browser2.newPage(); + + for (const page of [page1, page2, page3]) { + await page.setContent(` + +
hover me
+ `); + } + + for (const page of [page1, page2, page3]) + await page.hover('span'); + for (const page of [page1, page2, page3]) + await page.click('button'); + for (const page of [page1, page2, page3]) + expect(await page.evaluate('window.clicked')).toBe(1); + for (const page of [page1, page2, page3]) + await page.click('button'); + for (const page of [page1, page2, page3]) + expect(await page.evaluate('window.clicked')).toBe(2); +}); diff --git a/tests/page/elementhandle-convenience.spec.ts b/tests/page/elementhandle-convenience.spec.ts index 21ef4c85bfe1b..5020f34ea8a9a 100644 --- a/tests/page/elementhandle-convenience.spec.ts +++ b/tests/page/elementhandle-convenience.spec.ts @@ -30,6 +30,13 @@ it('should have a nice preview', async ({ page, server }) => { expect(String(check)).toBe('JSHandle@'); }); +it('should have a nice preview for non-ascii attributes/children', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(`
${'πŸ˜›'.repeat(100)}`); + const handle = await page.$('div'); + await expect.poll(() => String(handle)).toBe(`JSHandle@
πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›πŸ˜›β€¦
`); +}); + it('getAttribute should work', async ({ page, server }) => { await page.goto(`${server.PREFIX}/dom.html`); const handle = await page.$('#outer'); diff --git a/tests/page/locator-frame.spec.ts b/tests/page/locator-frame.spec.ts index 50151a4257817..08da3dff0ab33 100644 --- a/tests/page/locator-frame.spec.ts +++ b/tests/page/locator-frame.spec.ts @@ -268,3 +268,33 @@ it('wait for hidden should succeed when frame is not in dom', async ({ page }) = const error = await button.waitFor({ state: 'attached', timeout: 1000 }).catch(e => e); expect(error.message).toContain('Timeout 1000ms exceeded'); }); + +it('should work with COEP/COOP/CORP isolated iframe', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28082' }); + it.fixme(browserName === 'firefox'); + await page.route('**/empty.html', route => { + return route.fulfill({ + body: ``, + contentType: 'text/html', + headers: { + 'cross-origin-embedder-policy': 'require-corp', + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'cross-origin', + } + }); + }); + await page.route('**/btn.html', route => { + return route.fulfill({ + body: '', + contentType: 'text/html', + headers: { + 'cross-origin-embedder-policy': 'require-corp', + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'cross-origin', + } + }); + }); + await page.goto(server.EMPTY_PAGE); + await page.frameLocator('iframe').getByRole('button').click(); + expect(await page.frames()[1].evaluate(() => window['__clicked'])).toBe(true); +}); diff --git a/tests/page/page-dispatchevent.spec.ts b/tests/page/page-dispatchevent.spec.ts index 85b928862b75b..44dfa90592499 100644 --- a/tests/page/page-dispatchevent.spec.ts +++ b/tests/page/page-dispatchevent.spec.ts @@ -171,3 +171,25 @@ it('should dispatch wheel event', async ({ page, server }) => { expect(await eventsHandle.evaluate(e => e[0] instanceof WheelEvent)).toBeTruthy(); expect(await eventsHandle.evaluate(e => ({ deltaX: e[0].deltaX, deltaY: e[0].deltaY }))).toEqual({ deltaX: 100, deltaY: 200 }); }); + +it('should dispatch device orientation event', async ({ page, server, isAndroid }) => { + it.skip(isAndroid, 'DeviceOrientationEvent is only available in a secure context. While Androids loopback is not treated as secure.'); + await page.goto(server.PREFIX + '/device-orientation.html'); + await page.locator('html').dispatchEvent('deviceorientation', { alpha: 10, beta: 20, gamma: 30 }); + expect(await page.evaluate('result')).toBe('Oriented'); + expect(await page.evaluate('alpha')).toBe(10); + expect(await page.evaluate('beta')).toBe(20); + expect(await page.evaluate('gamma')).toBe(30); + expect(await page.evaluate('absolute')).toBeFalsy(); +}); + +it('should dispatch absolute device orientation event', async ({ page, server, isAndroid }) => { + it.skip(isAndroid, 'DeviceOrientationEvent is only available in a secure context. While Androids loopback is not treated as secure.'); + await page.goto(server.PREFIX + '/device-orientation.html'); + await page.locator('html').dispatchEvent('deviceorientationabsolute', { alpha: 10, beta: 20, gamma: 30, absolute: true }); + expect(await page.evaluate('result')).toBe('Oriented'); + expect(await page.evaluate('alpha')).toBe(10); + expect(await page.evaluate('beta')).toBe(20); + expect(await page.evaluate('gamma')).toBe(30); + expect(await page.evaluate('absolute')).toBeTruthy(); +}); diff --git a/tests/page/page-wait-for-request.spec.ts b/tests/page/page-wait-for-request.spec.ts index 555ace31ad6aa..3ba6e9304fbd8 100644 --- a/tests/page/page-wait-for-request.spec.ts +++ b/tests/page/page-wait-for-request.spec.ts @@ -63,7 +63,7 @@ it('should respect default timeout', async ({ page, playwright }) => { it('should log the url', async ({ page }) => { const error = await page.waitForRequest('long-long-long-long-long-long-long-long-long-long-long-long-long-long.css', { timeout: 1000 }).catch(e => e); - expect(error.message).toContain('waiting for request "long-long-long-long-long-long-long-long-long-long-…"'); + expect(error.message).toContain('waiting for request "long-long-long-long-long-long-long-long-long-long…"'); }); it('should work with no timeout', async ({ page, server }) => { diff --git a/utils/docker/Dockerfile.jammy b/utils/docker/Dockerfile.jammy index fc0364de5c4ed..d7c70a729cf8e 100644 --- a/utils/docker/Dockerfile.jammy +++ b/utils/docker/Dockerfile.jammy @@ -40,6 +40,15 @@ RUN mkdir /ms-playwright && \ npm i /tmp/playwright-core.tar.gz && \ npm exec --no -- playwright-core mark-docker-image "${DOCKER_IMAGE_NAME_TEMPLATE}" && \ npm exec --no -- playwright-core install --with-deps && rm -rf /var/lib/apt/lists/* && \ + # Workaround for https://github.com/microsoft/playwright/issues/27313 + # While the gstreamer plugin load process can be in-process, it ended up throwing + # an error that it can't have libsoup2 and libsoup3 in the same process because + # libgstwebrtc is linked against libsoup2. So we just remove the plugin. + if [ "$(uname -m)" = "aarch64" ]; then \ + rm /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstwebrtc.so; \ + else \ + rm /usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstwebrtc.so; \ + fi && \ rm /tmp/playwright-core.tar.gz && \ rm -rf /ms-playwright-agent && \ rm -rf ~/.npm/ && \