diff --git a/README.md b/README.md index 48c0014914..d0d8e53dd0 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-129.0.6668.29-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-129.0.6668.42-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 129.0.6668.29 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 129.0.6668.42 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 130.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index 6c357ba3b5..b6a249b0eb 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-dotnet:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/dotnet:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 2b9789e8bc..89a49f7451 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright-java:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright/java:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index 985c19d388..7defa1c0d1 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-python:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/python:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as bytes instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 3a1834117c..23974d6523 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1134", + "revision": "1135", "installByDefault": true, - "browserVersion": "129.0.6668.29" + "browserVersion": "129.0.6668.42" }, { "name": "chromium-tip-of-tree", - "revision": "1256", + "revision": "1259", "installByDefault": false, - "browserVersion": "130.0.6695.0" + "browserVersion": "130.0.6713.0" }, { "name": "firefox", @@ -42,7 +42,11 @@ { "name": "ffmpeg", "revision": "1010", - "installByDefault": true + "installByDefault": true, + "revisionOverrides": { + "mac12": "1010", + "mac12-arm64": "1010" + } }, { "name": "android", diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 9c68271e80..d8fa8230c6 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -348,10 +348,10 @@ type CaptureOptions = { fullPage: boolean; }; -async function launchContext(options: Options, headless: boolean, executablePath?: string): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { +async function launchContext(options: Options, extraOptions: LaunchOptions): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { validateOptions(options); const browserType = lookupBrowserType(options); - const launchOptions: LaunchOptions = { headless, executablePath }; + const launchOptions: LaunchOptions = extraOptions; if (options.channel) launchOptions.channel = options.channel as any; launchOptions.handleSIGINT = false; @@ -363,7 +363,7 @@ async function launchContext(options: Options, headless: boolean, executablePath // In headful mode, use host device scale factor for things to look nice. // In headless, keep things the way it works in Playwright by default. // Assume high-dpi on MacOS. TODO: this is not perfect. - if (!headless) + if (!extraOptions.headless) contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1; // Work around the WebKit GTK scrolling issue. @@ -547,7 +547,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi } async function open(options: Options, url: string | undefined, language: string) { - const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); + const { context, launchOptions, contextOptions } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH }); await context._enableRecorder({ language, launchOptions, @@ -560,8 +560,17 @@ async function open(options: Options, url: string | undefined, language: string) async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) { const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; - const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); + const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`); + const { context, launchOptions, contextOptions } = await launchContext(options, { + headless: !!process.env.PWTEST_CLI_HEADLESS, + executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH, + tracesDir, + }); dotenv.config({ path: 'playwright.env' }); + if (process.env.PW_RECORDER_IS_TRACE_VIEWER) { + await fs.promises.mkdir(tracesDir, { recursive: true }); + await context.tracing.start({ name: 'trace', _live: true }); + } await context._enableRecorder({ language, launchOptions, @@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) { } async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) { - const { context } = await launchContext(options, true); + const { context } = await launchContext(options, { headless: true }); console.log('Navigating to ' + url); const page = await openPage(context, url); await waitForPage(page, captureOptions); @@ -600,7 +609,7 @@ async function screenshot(options: Options, captureOptions: CaptureOptions, url: async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) { if (options.browser !== 'chromium') throw new Error('PDF creation is only working with Chromium'); - const { context } = await launchContext({ ...options, browser: 'chromium' }, true); + const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true }); console.log('Navigating to ' + url); const page = await openPage(context, url); await waitForPage(page, captureOptions); diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index f11435a0c2..2244a372fc 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; +import { sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { escapeWithQuotes, asLocator } from '../../utils'; import { deviceDescriptors } from '../deviceDescriptors'; @@ -112,7 +112,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator { let method = 'Click'; if (action.clickCount === 2) method = 'DblClick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); if (!Object.entries(options).length) return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`; const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options'); diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 47c6fa3619..3a640d36e2 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -17,7 +17,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type * as types from '../types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; +import { toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { JavaScriptFormatter } from './javascript'; import { escapeWithQuotes, asLocator } from '../../utils'; @@ -101,7 +101,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsText = formatClickOptions(options); return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`; } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index 1c1ba3f1cb..c3ed05d4d4 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; +import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { escapeWithQuotes, asLocator } from '../../utils'; @@ -85,7 +85,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsString = formatOptions(options, false); return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`; } diff --git a/packages/playwright-core/src/server/codegen/language.ts b/packages/playwright-core/src/server/codegen/language.ts index 72cfb9083d..3e0c8f71e5 100644 --- a/packages/playwright-core/src/server/codegen/language.ts +++ b/packages/playwright-core/src/server/codegen/language.ts @@ -69,13 +69,14 @@ export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModif return result; } -export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { +export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions { const modifiers = toKeyboardModifiers(action.modifiers); const options: types.MouseClickOptions = {}; if (action.button !== 'left') options.button = action.button; if (modifiers.length) options.modifiers = modifiers; + // Do not render clickCount === 2 for dblclick. if (action.clickCount > 2) options.clickCount = action.clickCount; if (action.position) diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 6ed101bcf0..6c2b60dc70 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; +import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language'; import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils'; import { deviceDescriptors } from '../deviceDescriptors'; @@ -94,7 +94,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsString = formatOptions(options, false); return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; } diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 384f6f2377..efb2801f2c 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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -1098,7 +1098,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1109,7 +1109,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1120,7 +1120,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1131,7 +1131,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1142,7 +1142,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1153,7 +1153,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1164,7 +1164,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1175,7 +1175,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1186,7 +1186,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1197,7 +1197,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1219,7 +1219,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1230,7 +1230,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1241,7 +1241,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1252,7 +1252,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1263,7 +1263,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1274,7 +1274,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1285,7 +1285,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1296,7 +1296,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1307,7 +1307,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1362,7 +1362,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1373,7 +1373,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1384,7 +1384,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1395,7 +1395,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1406,7 +1406,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1417,7 +1417,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 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/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1428,7 +1428,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1439,7 +1439,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1450,7 +1450,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1465,7 +1465,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1480,7 +1480,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1495,7 +1495,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1510,7 +1510,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1525,7 +1525,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1540,7 +1540,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1551,7 +1551,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1562,7 +1562,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1577,7 +1577,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36 Edg/129.0.6668.29", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42", "screen": { "width": 1792, "height": 1120 @@ -1622,7 +1622,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1637,7 +1637,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36 Edg/129.0.6668.29", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index c70d8e825a..5c8fa550a7 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -39,6 +39,7 @@ import type { Dialog } from '../dialog'; import type { ConsoleMessage } from '../console'; import { serializeError } from '../errors'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; +import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer'; import { RecorderApp } from '../recorder/recorderApp'; export class BrowserContextDispatcher extends Dispatcher implements channels.BrowserContextChannel { @@ -292,7 +293,8 @@ export class BrowserContextDispatcher extends Dispatcher { - await Recorder.show(this._context, RecorderApp.factory(this._context), params); + const factory = process.env.PW_RECORDER_IS_TRACE_VIEWER ? RecorderInTraceViewer.factory(this._context) : RecorderApp.factory(this._context); + await Recorder.show(this._context, factory, params); } async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) { diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 8cbf11964f..48639fefc8 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -36,6 +36,7 @@ interface RecorderTool { cursor(): string; cleanup?(): void; onClick?(event: MouseEvent): void; + onDblClick?(event: MouseEvent): void; onContextMenu?(event: MouseEvent): void; onDragStart?(event: DragEvent): void; onInput?(event: Event): void; @@ -210,6 +211,7 @@ class RecordActionTool implements RecorderTool { private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModel | null = null; private _expectProgrammaticKeyUp = false; + private _pendingClickAction: { action: actions.ClickAction, timeout: NodeJS.Timeout } | undefined; constructor(recorder: Recorder) { this._recorder = recorder; @@ -252,6 +254,38 @@ class RecordActionTool implements RecorderTool { return; } + this._cancelPendingClickAction(); + + // Stall click in case we are observing double-click. + if (event.detail === 1) { + this._pendingClickAction = { + action: { + name: 'click', + selector: this._hoveredModel!.selector, + position: positionForEvent(event), + signals: [], + button: buttonForEvent(event), + modifiers: modifiersForEvent(event), + clickCount: event.detail + }, + timeout: setTimeout(() => this._commitPendingClickAction(), 200) + }; + } + } + + onDblClick(event: MouseEvent) { + if (isRangeInput(this._hoveredElement)) + return; + if (this._shouldIgnoreMouseEvent(event)) + return; + // Only allow double click dispatch while action is in progress. + if (this._actionInProgress(event)) + return; + if (this._consumedDueToNoModel(event, this._hoveredModel)) + return; + + this._cancelPendingClickAction(); + this._performAction({ name: 'click', selector: this._hoveredModel!.selector, @@ -263,6 +297,18 @@ class RecordActionTool implements RecorderTool { }); } + private _commitPendingClickAction() { + if (this._pendingClickAction) + this._performAction(this._pendingClickAction.action); + this._cancelPendingClickAction(); + } + + private _cancelPendingClickAction() { + if (this._pendingClickAction) + clearTimeout(this._pendingClickAction.timeout); + this._pendingClickAction = undefined; + } + onContextMenu(event: MouseEvent) { // the 'contextmenu' event is triggered by a right-click or equivalent action, // and it prevents the click event from firing for that action, so we always @@ -915,6 +961,10 @@ class Overlay { } return false; } + + onDblClick(event: MouseEvent) { + return false; + } } export class Recorder { @@ -970,6 +1020,7 @@ export class Recorder { this._listeners = [ addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true), addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true), + addEventListener(this.document, 'dblclick', event => this._onDblClick(event as MouseEvent), true), addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true), addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true), addEventListener(this.document, 'input', event => this._onInput(event), true), @@ -1043,6 +1094,16 @@ export class Recorder { this._currentTool.onClick?.(event); } + private _onDblClick(event: MouseEvent) { + if (!event.isTrusted) + return; + if (this.overlay?.onDblClick(event)) + return; + if (this._ignoreOverlayEvent(event)) + return; + this._currentTool.onDblClick?.(event); + } + private _onContextMenu(event: MouseEvent) { if (!event.isTrusted) return; diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 97316c2f9e..5e197f871f 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -26,14 +26,12 @@ import { type Language } from './codegen/types'; import { Debugger } from './debugger'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder'; -import { type IRecorderApp } from './recorder/recorderApp'; +import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/recorderFrontend'; import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils'; const recorderSymbol = Symbol('recorderSymbol'); -export type RecorderAppFactory = (recorder: Recorder) => Promise; - -export class Recorder implements InstrumentationListener { +export class Recorder implements InstrumentationListener, IRecorder { private _context: BrowserContext; private _mode: Mode; private _highlightedSelector = ''; @@ -47,14 +45,14 @@ export class Recorder implements InstrumentationListener { private _omitCallTracking = false; private _currentLanguage: Language; - static showInspector(context: BrowserContext, recorderAppFactory: RecorderAppFactory) { + static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) { const params: channels.BrowserContextRecorderSupplementEnableParams = {}; if (isUnderTest()) params.language = process.env.TEST_INSPECTOR_LANGUAGE; Recorder.show(context, recorderAppFactory, params).catch(() => {}); } - static show(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { let recorderPromise = (context as any)[recorderSymbol] as Promise; if (!recorderPromise) { recorderPromise = Recorder._create(context, recorderAppFactory, params); @@ -63,7 +61,7 @@ export class Recorder implements InstrumentationListener { return recorderPromise; } - private static async _create(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { const recorder = new Recorder(context, params); const recorderApp = await recorderAppFactory(recorder); await recorder._install(recorderApp); diff --git a/packages/playwright-core/src/server/recorder/DEPS.list b/packages/playwright-core/src/server/recorder/DEPS.list index 22ec3dfc2f..f3bbfc23bf 100644 --- a/packages/playwright-core/src/server/recorder/DEPS.list +++ b/packages/playwright-core/src/server/recorder/DEPS.list @@ -10,3 +10,6 @@ ../../utils/** ../../utilsBundle.ts ../../zipBundle.ts + +[recorderInTraceViewer.ts] +../trace/viewer/traceViewer.ts diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 17d2c2c130..9b4efb9e65 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -69,13 +69,13 @@ export class ContextRecorder extends EventEmitter { // Make a copy of options to modify them later. const languageGeneratorOptions: LanguageGeneratorOptions = { browserName: context._browser.options.name, - launchOptions: { headless: false, ...params.launchOptions }, + launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined }, contextOptions: { ...params.contextOptions }, deviceName: params.device, saveStorage: params.saveStorage, }; - const collection = new RecorderCollection(params.mode === 'recording'); + const collection = new RecorderCollection(this._pageAliases, params.mode === 'recording'); collection.on('change', () => { this._recorderSources = []; for (const languageGenerator of this._orderedLanguages) { @@ -163,7 +163,7 @@ export class ContextRecorder extends EventEmitter { // First page is called page, others are called popup1, popup2, etc. const frame = page.mainFrame(); page.on('close', () => { - this._collection.addAction({ + this._collection.addRecordedAction({ frame: this._describeMainFrame(page), committed: true, action: { @@ -185,7 +185,7 @@ export class ContextRecorder extends EventEmitter { if (page.opener()) { this._onPopup(page.opener()!, page); } else { - this._collection.addAction({ + this._collection.addRecordedAction({ frame: this._describeMainFrame(page), committed: true, action: { @@ -236,14 +236,15 @@ export class ContextRecorder extends EventEmitter { await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); - this._collection.willPerformAction(actionInContext); - const success = await performAction(this._pageAliases, actionInContext); - if (success) { - this._collection.didPerformAction(actionInContext); + const callMetadata = await this._collection.willPerformAction(actionInContext); + if (!callMetadata) + return; + const error = await performAction(callMetadata, this._pageAliases, actionInContext).then(() => undefined).catch((e: Error) => e); + await this._collection.didPerformAction(callMetadata, actionInContext, error); + if (error) + actionInContext.committed = true; + else this._setCommittedAfterTimeout(actionInContext); - } else { - this._collection.performedActionFailed(actionInContext); - } } private async _recordAction(frame: Frame, action: actions.Action) { @@ -260,7 +261,7 @@ export class ContextRecorder extends EventEmitter { await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); this._setCommittedAfterTimeout(actionInContext); - this._collection.addAction(actionInContext); + this._collection.addRecordedAction(actionInContext); } private _setCommittedAfterTimeout(actionInContext: ActionInContext) { diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 0faf191ea5..8044fadf41 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -24,9 +24,9 @@ import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes'; import { isUnderTest } from '../../utils'; import { mime } from '../../utilsBundle'; import { syncLocalStorageWithSettings } from '../launchApp'; -import type { Recorder, RecorderAppFactory } from '../recorder'; import type { BrowserContext } from '../browserContext'; import { launchApp } from '../launchApp'; +import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; declare global { interface Window { @@ -42,16 +42,6 @@ declare global { } } -export interface IRecorderApp extends EventEmitter { - close(): Promise; - setPaused(paused: boolean): Promise; - setMode(mode: Mode): Promise; - setFileIfNeeded(file: string): Promise; - setSelector(selector: string, userGesture?: boolean): Promise; - updateCallLogs(callLogs: CallLog[]): Promise; - setSources(sources: Source[]): Promise; -} - export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} @@ -65,9 +55,9 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp { private _page: Page; readonly wsEndpoint: string | undefined; - private _recorder: Recorder; + private _recorder: IRecorder; - constructor(recorder: Recorder, page: Page, wsEndpoint: string | undefined) { + constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) { super(); this.setMaxListeners(0); this._recorder = recorder; @@ -113,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html'); } - static factory(context: BrowserContext): RecorderAppFactory { + static factory(context: BrowserContext): IRecorderAppFactory { return async recorder => { if (process.env.PW_CODEGEN_NO_INSPECTOR) return new EmptyRecorderApp(); @@ -121,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }; } - private static async _open(recorder: Recorder, inspectedContext: BrowserContext): Promise { + private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise { const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage; const headed = !!inspectedContext._browser.options.headful; const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index 29da778ffb..fbfbf8f26e 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -16,23 +16,28 @@ import { EventEmitter } from 'events'; import type { Frame } from '../frames'; +import type { Page } from '../page'; import type { Signal } from './recorderActions'; import type { ActionInContext } from '../codegen/types'; +import type { CallMetadata } from '@protocol/callMetadata'; +import { createGuid } from '../../utils/crypto'; +import { monotonicTime } from '../../utils/time'; +import { mainFrameForAction, traceParamsForAction } from './recorderUtils'; export class RecorderCollection extends EventEmitter { - private _currentAction: ActionInContext | null = null; private _lastAction: ActionInContext | null = null; private _actions: ActionInContext[] = []; private _enabled: boolean; + private _pageAliases: Map; - constructor(enabled: boolean) { + constructor(pageAliases: Map, enabled: boolean) { super(); this._enabled = enabled; + this._pageAliases = pageAliases; this.restart(); } restart() { - this._currentAction = null; this._lastAction = null; this._actions = []; this.emit('change'); @@ -46,60 +51,63 @@ export class RecorderCollection extends EventEmitter { this._enabled = enabled; } - addAction(action: ActionInContext) { + async willPerformAction(actionInContext: ActionInContext): Promise { if (!this._enabled) - return; - this.willPerformAction(action); - this.didPerformAction(action); + return null; + const { callMetadata, mainFrame } = this._callMetadataForAction(actionInContext); + await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); + this._lastAction = actionInContext; + return callMetadata; } - willPerformAction(action: ActionInContext) { - if (!this._enabled) - return; - this._currentAction = action; + private _callMetadataForAction(actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } { + const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); + const { action } = actionInContext; + const callMetadata: CallMetadata = { + id: `call@${createGuid()}`, + apiName: 'frame.' + action.name, + objectId: mainFrame.guid, + pageId: mainFrame._page.guid, + frameId: mainFrame.guid, + startTime: monotonicTime(), + endTime: 0, + type: 'Frame', + method: action.name, + params: traceParamsForAction(actionInContext), + log: [], + }; + return { callMetadata, mainFrame }; } - performedActionFailed(action: ActionInContext) { + async didPerformAction(callMetadata: CallMetadata, actionInContext: ActionInContext, error?: Error) { if (!this._enabled) return; - if (this._currentAction === action) - this._currentAction = null; + + if (!error) + this._actions.push(actionInContext); + + const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); + callMetadata.endTime = monotonicTime(); + await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); + + this.emit('change'); } - didPerformAction(actionInContext: ActionInContext) { + addRecordedAction(actionInContext: ActionInContext) { if (!this._enabled) return; const action = actionInContext.action; - let eraseLastAction = false; - if (this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias) { - const lastAction = this._lastAction.action; - // We augment last action based on the type. - if (this._lastAction && action.name === 'fill' && lastAction.name === 'fill') { - if (action.selector === lastAction.selector) - eraseLastAction = true; - } - if (lastAction && action.name === 'click' && lastAction.name === 'click') { - if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount) - eraseLastAction = true; - } - if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') { - if (action.url === lastAction.url) { - // Already at a target URL. - this._currentAction = null; - return; - } - } - // Check and uncheck erase click. - if (lastAction && (action.name === 'check' || action.name === 'uncheck') && lastAction.name === 'click') { - if (action.selector === lastAction.selector) - eraseLastAction = true; - } + + const lastAction = this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias ? this._lastAction.action : undefined; + if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate' && action.url === lastAction.url) { + // Already at a target URL. + return; } - this._lastAction = actionInContext; - this._currentAction = null; - if (eraseLastAction) + if (lastAction && action.name === 'fill' && lastAction.name === 'fill' && action.selector === lastAction.selector) this._actions.pop(); + + this._lastAction = actionInContext; this._actions.push(actionInContext); this.emit('change'); } @@ -116,25 +124,14 @@ export class RecorderCollection extends EventEmitter { if (!this._enabled) return; - // Signal either arrives while action is being performed or shortly after. - if (this._currentAction) { - this._currentAction.action.signals.push(signal); - return; - } - - if (this._lastAction && (!this._lastAction.committed || signal.name !== 'navigation')) { - const signals = this._lastAction.action.signals; - if (signal.name === 'navigation' && signals.length && signals[signals.length - 1].name === 'download') - return; - if (signal.name === 'download' && signals.length && signals[signals.length - 1].name === 'navigation') - signals.length = signals.length - 1; + if (this._lastAction && !this._lastAction.committed) { this._lastAction.action.signals.push(signal); this.emit('change'); return; } if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { - this.addAction({ + this.addRecordedAction({ frame: { pageAlias, framePath: [], diff --git a/packages/playwright-core/src/server/recorder/recorderFrontend.ts b/packages/playwright-core/src/server/recorder/recorderFrontend.ts new file mode 100644 index 0000000000..161aa71eca --- /dev/null +++ b/packages/playwright-core/src/server/recorder/recorderFrontend.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CallLog, Mode, Source } from '@recorder/recorderTypes'; +import type { EventEmitter } from 'events'; + +export interface IRecorder { + setMode(mode: Mode): void; + mode(): Mode; +} + +export interface IRecorderApp extends EventEmitter { + close(): Promise; + setPaused(paused: boolean): Promise; + setMode(mode: Mode): Promise; + setFileIfNeeded(file: string): Promise; + setSelector(selector: string, userGesture?: boolean): Promise; + updateCallLogs(callLogs: CallLog[]): Promise; + setSources(sources: Source[]): Promise; +} + +export type IRecorderAppFactory = (recorder: IRecorder) => Promise; diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts new file mode 100644 index 0000000000..f7613ffc54 --- /dev/null +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -0,0 +1,94 @@ +/** + * 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 path from 'path'; +import type { CallLog, Mode, Source } from '@recorder/recorderTypes'; +import { EventEmitter } from 'events'; +import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; +import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer'; +import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer'; +import type { BrowserContext } from '../browserContext'; +import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher'; +import type { Transport } from '../../utils/httpServer'; + +export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp { + private _recorder: IRecorder; + private _transport: Transport; + + static factory(context: BrowserContext): IRecorderAppFactory { + return async (recorder: IRecorder) => { + const transport = new RecorderTransport(); + const trace = path.join(context._browser.options.tracesDir, 'trace'); + await openApp(trace, { transport }); + return new RecorderInTraceViewer(context, recorder, transport); + }; + } + + constructor(context: BrowserContext, recorder: IRecorder, transport: Transport) { + super(); + this._recorder = recorder; + this._transport = transport; + } + + async close(): Promise { + this._transport.sendEvent?.('close', {}); + } + + async setPaused(paused: boolean): Promise { + this._transport.sendEvent?.('setPaused', { paused }); + } + + async setMode(mode: Mode): Promise { + this._transport.sendEvent?.('setMode', { mode }); + } + + async setFileIfNeeded(file: string): Promise { + this._transport.sendEvent?.('setFileIfNeeded', { file }); + } + + async setSelector(selector: string, userGesture?: boolean): Promise { + this._transport.sendEvent?.('setSelector', { selector, userGesture }); + } + + async updateCallLogs(callLogs: CallLog[]): Promise { + this._transport.sendEvent?.('updateCallLogs', { callLogs }); + } + + async setSources(sources: Source[]): Promise { + this._transport.sendEvent?.('setSources', { sources }); + } +} + +async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }) { + const server = await startTraceViewerServer(options); + await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' }); + const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options); + page.on('close', () => gracefullyProcessExitDoNotHang(0)); +} + +class RecorderTransport implements Transport { + constructor() { + } + + async dispatch(method: string, params: any) { + } + + onclose() { + } + + sendEvent?: (method: string, params: any) => void; + close?: () => void; +} diff --git a/packages/playwright-core/src/server/recorder/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts index b6bdfd1a72..f5358d6097 100644 --- a/packages/playwright-core/src/server/recorder/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -14,115 +14,130 @@ * limitations under the License. */ -import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils'; -import { toClickOptions, toKeyboardModifiers } from '../codegen/language'; +import { serializeExpectedTextValues } from '../../utils'; +import { toKeyboardModifiers } from '../codegen/language'; import type { ActionInContext } from '../codegen/types'; -import type { Frame } from '../frames'; import type { CallMetadata } from '../instrumentation'; import type { Page } from '../page'; -import { buildFullSelector } from './recorderUtils'; +import type * as actions from './recorderActions'; +import type * as types from '../types'; +import { buildFullSelector, mainFrameForAction } from './recorderUtils'; -async function innerPerformAction(mainFrame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise): Promise { - const callMetadata: CallMetadata = { - id: `call@${createGuid()}`, - apiName: 'frame.' + action, - objectId: mainFrame.guid, - pageId: mainFrame._page.guid, - frameId: mainFrame.guid, - startTime: monotonicTime(), - endTime: 0, - type: 'Frame', - method: action, - params, - log: [], - }; - - try { - await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); - await cb(callMetadata); - } catch (e) { - callMetadata.endTime = monotonicTime(); - await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); - return false; - } - - callMetadata.endTime = monotonicTime(); - await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); - return true; -} - -export async function performAction(pageAliases: Map, actionInContext: ActionInContext): Promise { - const pageAlias = actionInContext.frame.pageAlias; - const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; - if (!page) - throw new Error('Internal error: page not found'); - const mainFrame = page.mainFrame(); +export async function performAction(callMetadata: CallMetadata, pageAliases: Map, actionInContext: ActionInContext) { + const mainFrame = mainFrameForAction(pageAliases, actionInContext); const { action } = actionInContext; + const kActionTimeout = 5000; - if (action.name === 'navigate') - return await innerPerformAction(mainFrame, 'goto', { url: action.url }, callMetadata => mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout })); + if (action.name === 'navigate') { + await mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout }); + return; + } + if (action.name === 'openPage') throw Error('Not reached'); - if (action.name === 'closePage') - return await innerPerformAction(mainFrame, 'close', {}, callMetadata => mainFrame._page.close(callMetadata)); + + if (action.name === 'closePage') { + await mainFrame._page.close(callMetadata); + return; + } const selector = buildFullSelector(actionInContext.frame.framePath, action.selector); if (action.name === 'click') { const options = toClickOptions(action); - return await innerPerformAction(mainFrame, 'click', { selector }, callMetadata => mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true })); + await mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true }); + return; } + if (action.name === 'press') { const modifiers = toKeyboardModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return await innerPerformAction(mainFrame, 'press', { selector, key: shortcut }, callMetadata => mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true })); + await mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true }); + return; } - if (action.name === 'fill') - return await innerPerformAction(mainFrame, 'fill', { selector, text: action.text }, callMetadata => mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true })); - if (action.name === 'setInputFiles') - return await innerPerformAction(mainFrame, 'setInputFiles', { selector, files: action.files }, callMetadata => mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true })); - if (action.name === 'check') - return await innerPerformAction(mainFrame, 'check', { selector }, callMetadata => mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true })); - if (action.name === 'uncheck') - return await innerPerformAction(mainFrame, 'uncheck', { selector }, callMetadata => mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true })); + + if (action.name === 'fill') { + await mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'setInputFiles') { + await mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'check') { + await mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'uncheck') { + await mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true }); + return; + } + if (action.name === 'select') { const values = action.options.map(value => ({ value })); - return await innerPerformAction(mainFrame, 'selectOption', { selector, values }, callMetadata => mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true })); + await mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true }); + return; } + if (action.name === 'assertChecked') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.be.checked', isNot: !action.checked, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertText') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.have.text', expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), isNot: false, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertValue') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.have.value', expectedValue: action.value, isNot: false, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertVisible') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.be.visible', isNot: false, timeout: kActionTimeout, - })); + }); + return; } + throw new Error('Internal error: unexpected action ' + (action as any).name); } + +export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { + const modifiers = toKeyboardModifiers(action.modifiers); + const options: types.MouseClickOptions = {}; + if (action.button !== 'left') + options.button = action.button; + if (modifiers.length) + options.modifiers = modifiers; + if (action.clickCount > 1) + options.clickCount = action.clickCount; + if (action.position) + options.position = action.position; + return options; +} diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index b4949115d2..234fc79a0f 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -20,6 +20,8 @@ import type { Page } from '../page'; import type { ActionInContext } from '../codegen/types'; import type { Frame } from '../frames'; import type * as actions from './recorderActions'; +import { toKeyboardModifiers } from '../codegen/language'; +import { serializeExpectedTextValues } from '../../utils/expectUtils'; export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { let title = metadata.apiName || metadata.method; @@ -72,3 +74,58 @@ export async function frameForAction(pageAliases: Map, actionInCon throw new Error('Internal error: frame not found'); return result.frame; } + +export function traceParamsForAction(actionInContext: ActionInContext) { + const { action } = actionInContext; + + switch (action.name) { + case 'navigate': return { url: action.url }; + case 'openPage': return {}; + case 'closePage': return {}; + } + + const selector = buildFullSelector(actionInContext.frame.framePath, action.selector); + switch (action.name) { + case 'click': return { selector, clickCount: action.clickCount }; + case 'press': { + const modifiers = toKeyboardModifiers(action.modifiers); + const shortcut = [...modifiers, action.key].join('+'); + return { selector, key: shortcut }; + } + case 'fill': return { selector, text: action.text }; + case 'setInputFiles': return { selector, files: action.files }; + case 'check': return { selector }; + case 'uncheck': return { selector }; + case 'select': return { selector, values: action.options.map(value => ({ value })) }; + case 'assertChecked': { + return { + selector, + expression: 'to.be.checked', + isNot: !action.checked, + }; + } + case 'assertText': { + return { + selector, + expression: 'to.have.text', + expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), + isNot: false, + }; + } + case 'assertValue': { + return { + selector, + expression: 'to.have.value', + expectedValue: action.value, + isNot: false, + }; + } + case 'assertVisible': { + return { + selector, + expression: 'to.be.visible', + isNot: false, + }; + } + } +} diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index ea796bfc72..9c426fd86e 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -16,6 +16,7 @@ import { captureRawStack, + createGuid, isString, pollAgainstDeadline } from 'playwright-core/lib/utils'; import type { ExpectZone } from 'playwright-core/lib/utils'; @@ -104,11 +105,17 @@ export const printReceivedStringContainExpectedResult = ( type ExpectMessage = string | { message?: string }; -function createMatchers(actual: unknown, info: ExpectMetaInfo): any { - return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info)); +function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]): any { + return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info, prefix)); } -function createExpect(info: ExpectMetaInfo) { +const getCustomMatchersSymbol = Symbol('get custom matchers'); + +function qualifiedMatcherName(qualifier: string[], matcherName: string) { + return qualifier.join(':') + '$' + matcherName; +} + +function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record) { const expectInstance: Expect<{}> = new Proxy(expectLibrary, { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { const [actual, messageOrOptions] = argumentsList; @@ -119,18 +126,22 @@ function createExpect(info: ExpectMetaInfo) { throw new Error('`expect.poll()` accepts only function as a first argument'); newInfo.generator = actual as any; } - return createMatchers(actual, newInfo); + return createMatchers(actual, newInfo, prefix); }, - get: function(target: any, property: string) { + get: function(target: any, property: string | typeof getCustomMatchersSymbol) { if (property === 'configure') return configure; if (property === 'extend') { return (matchers: any) => { + const qualifier = [...prefix, createGuid()]; + const wrappedMatchers: any = {}; + const extendedMatchers: any = { ...customMatchers }; for (const [name, matcher] of Object.entries(matchers)) { - wrappedMatchers[name] = function(...args: any[]) { + const key = qualifiedMatcherName(qualifier, name); + wrappedMatchers[key] = function(...args: any[]) { const { isNot, promise, utils } = this; const newThis: ExpectMatcherState = { isNot, @@ -141,9 +152,12 @@ function createExpect(info: ExpectMetaInfo) { (newThis as any).equals = throwUnsupportedExpectMatcherError; return (matcher as any).call(newThis, ...args); }; + Object.defineProperty(wrappedMatchers[key], 'name', { value: name }); + extendedMatchers[name] = wrappedMatchers[key]; } expectLibrary.extend(wrappedMatchers); - return expectInstance; + + return createExpect(info, qualifier, extendedMatchers); }; } @@ -153,6 +167,9 @@ function createExpect(info: ExpectMetaInfo) { }; } + if (property === getCustomMatchersSymbol) + return customMatchers; + if (property === 'poll') { return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { const poll = isString(messageOrOptions) ? {} : messageOrOptions || {}; @@ -178,7 +195,7 @@ function createExpect(info: ExpectMetaInfo) { newInfo.pollIntervals = configuration._poll.intervals; } } - return createExpect(newInfo); + return createExpect(newInfo, prefix, customMatchers); }; return expectInstance; @@ -241,15 +258,28 @@ type ExpectMetaInfo = { class ExpectMetaInfoProxyHandler implements ProxyHandler { private _info: ExpectMetaInfo; + private _prefix: string[]; - constructor(info: ExpectMetaInfo) { + constructor(info: ExpectMetaInfo, prefix: string[]) { this._info = { ...info }; + this._prefix = prefix; } get(target: Object, matcherName: string | symbol, receiver: any): any { let matcher = Reflect.get(target, matcherName, receiver); if (typeof matcherName !== 'string') return matcher; + + let resolvedMatcherName = matcherName; + for (let i = this._prefix.length; i > 0; i--) { + const qualifiedName = qualifiedMatcherName(this._prefix.slice(0, i), matcherName); + if (Reflect.has(target, qualifiedName)) { + matcher = Reflect.get(target, qualifiedName, receiver); + resolvedMatcherName = qualifiedName; + break; + } + } + if (matcher === undefined) throw new Error(`expect: Property '${matcherName}' not found.`); if (typeof matcher !== 'function') { @@ -260,7 +290,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { if (this._info.isPoll) { if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects') throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`); - matcher = (...args: any[]) => pollMatcher(matcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args); + matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args); } return (...args: any[]) => { const testInfo = currentTestInfo(); @@ -320,7 +350,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } } -async function pollMatcher(matcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) { +async function pollMatcher(qualifiedMatcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) { const testInfo = currentTestInfo(); const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); @@ -333,7 +363,7 @@ async function pollMatcher(matcherName: any, isNot: boolean, pollIntervals: numb if (isNot) expectInstance = expectInstance.not; try { - expectInstance[matcherName].call(expectInstance, ...args); + expectInstance[qualifiedMatcherName].call(expectInstance, ...args); return { continuePolling: false, result: undefined }; } catch (error) { return { continuePolling: true, result: error }; @@ -375,8 +405,15 @@ function computeArgsSuffix(matcherName: string, args: any[]) { return value ? `(${value})` : ''; } -export const expect: Expect<{}> = createExpect({}).extend(customMatchers); +export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers); export function mergeExpects(...expects: any[]) { - return expect; + let merged = expect; + for (const e of expects) { + const internals = e[getCustomMatchersSymbol]; + if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special + continue; + merged = merged.extend(internals); + } + return merged; } diff --git a/packages/trace-viewer/recorder.html b/packages/trace-viewer/recorder.html new file mode 100644 index 0000000000..c33d6586e5 --- /dev/null +++ b/packages/trace-viewer/recorder.html @@ -0,0 +1,28 @@ + + + + + + + + Playwright Recorder + + +
+ + + diff --git a/packages/trace-viewer/src/recorder.tsx b/packages/trace-viewer/src/recorder.tsx new file mode 100644 index 0000000000..4de705d4fc --- /dev/null +++ b/packages/trace-viewer/src/recorder.tsx @@ -0,0 +1,41 @@ +/** + * 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 '@web/common.css'; +import { applyTheme } from '@web/theme'; +import '@web/third_party/vscode/codicon.css'; +import * as ReactDOM from 'react-dom/client'; +import { RecorderView } from './ui/recorderView'; + +(async () => { + applyTheme(); + + if (window.location.protocol !== 'file:') { + if (!navigator.serviceWorker) + throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`); + navigator.serviceWorker.register('sw.bundle.js'); + if (!navigator.serviceWorker.controller) { + await new Promise(f => { + navigator.serviceWorker.oncontrollerchange = () => f(); + }); + } + + // Keep SW running. + setInterval(function() { fetch('ping'); }, 10000); + } + + ReactDOM.createRoot(document.querySelector('#root')!).render(); +})(); diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index a2d3f5a86f..59989b89dd 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -16,6 +16,7 @@ .network-request-details-tab { width: 100%; + height: 100%; user-select: text; line-height: 24px; margin-left: 10px; diff --git a/packages/trace-viewer/src/ui/recorderView.css b/packages/trace-viewer/src/ui/recorderView.css new file mode 100644 index 0000000000..ad03e78e7d --- /dev/null +++ b/packages/trace-viewer/src/ui/recorderView.css @@ -0,0 +1,15 @@ +/* + 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. +*/ diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx new file mode 100644 index 0000000000..940fd146a9 --- /dev/null +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -0,0 +1,168 @@ +/* + 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 * as React from 'react'; +import './recorderView.css'; +import { MultiTraceModel } from './modelUtil'; +import type { SourceLocation } from './modelUtil'; +import { Workbench } from './workbench'; +import type { Mode, Source } from '@recorder/recorderTypes'; +import type { ContextEntry } from '../entries'; + +const searchParams = new URLSearchParams(window.location.search); +const guid = searchParams.get('ws'); +const trace = searchParams.get('trace') + '.json'; + +export const RecorderView: React.FunctionComponent = () => { + const [connection, setConnection] = React.useState(null); + const [sources, setSources] = React.useState([]); + React.useEffect(() => { + const wsURL = new URL(`../${guid}`, window.location.toString()); + wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); + const webSocket = new WebSocket(wsURL.toString()); + setConnection(new Connection(webSocket, { setSources })); + return () => { + webSocket.close(); + }; + }, []); + + React.useEffect(() => { + if (!connection) + return; + connection.setMode('recording'); + }, [connection]); + + return
+ +
; +}; + +export const TraceView: React.FC<{ + traceLocation: string, + sources: Source[], +}> = ({ traceLocation, sources }) => { + const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(null); + + React.useEffect(() => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + + // Start polling running test. + pollTimer.current = setTimeout(async () => { + try { + const model = await loadSingleTraceFile(traceLocation); + setModel({ model, isLive: true }); + } catch { + setModel(undefined); + } finally { + setCounter(counter + 1); + } + }, 500); + return () => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + }; + }, [counter, traceLocation]); + + const fallbackLocation = React.useMemo(() => { + if (!sources.length) + return undefined; + const fallbackLocation: SourceLocation = { + file: '', + line: 0, + column: 0, + source: { + errors: [], + content: sources[0].text + } + }; + return fallbackLocation; + }, [sources]); + + return ; +}; + +async function loadSingleTraceFile(url: string): Promise { + const params = new URLSearchParams(); + params.set('trace', url); + const response = await fetch(`contexts?${params.toString()}`); + const contextEntries = await response.json() as ContextEntry[]; + return new MultiTraceModel(contextEntries); +} + +class Connection { + private _lastId = 0; + private _webSocket: WebSocket; + private _callbacks = new Map void, reject: (arg: Error) => void }>(); + private _options: { setSources: (sources: Source[]) => void; }; + + constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) { + this._webSocket = webSocket; + this._callbacks = new Map(); + this._options = options; + + this._webSocket.addEventListener('message', event => { + const message = JSON.parse(event.data); + const { id, result, error, method, params } = message; + if (id) { + const callback = this._callbacks.get(id); + if (!callback) + return; + this._callbacks.delete(id); + if (error) + callback.reject(new Error(error)); + else + callback.resolve(result); + } else { + this._dispatchEvent(method, params); + } + }); + } + + setMode(mode: Mode) { + this._sendMessageNoReply('setMode', { mode }); + } + + private async _sendMessage(method: string, params?: any): Promise { + const id = ++this._lastId; + const message = { id, method, params }; + this._webSocket.send(JSON.stringify(message)); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject }); + }); + } + + private _sendMessageNoReply(method: string, params?: any) { + this._sendMessage(method, params).catch(() => { }); + } + + private _dispatchEvent(method: string, params?: any) { + if (method === 'setSources') { + const { sources } = params as { sources: Source[] }; + this._options.setSources(sources); + } + } +} diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index cf704a8438..ce54b34d53 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -55,7 +55,7 @@ export const SourceTab: React.FunctionComponent<{ let source = sources.get(file); // Fallback location can fall outside the sources model. if (!source) { - source = { errors: fallbackLocation?.source?.errors || [], content: undefined }; + source = { errors: fallbackLocation?.source?.errors || [], content: fallbackLocation?.source?.content }; sources.set(file, source); } @@ -66,7 +66,9 @@ export const SourceTab: React.FunctionComponent<{ highlight.push({ line: targetLine, type: 'running' }); // After the source update, but before the test run, don't trust the cache. - if (source.content === undefined || shouldUseFallback) { + if (fallbackLocation?.source?.content !== undefined) { + source.content = fallbackLocation.source.content; + } else if (source.content === undefined || shouldUseFallback) { const sha1 = await calculateSha1(file); try { let response = await fetch(`sha1/src@${sha1}.txt`); diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 2c4b61fdad..a97716bdc4 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -72,6 +72,7 @@ export const UIModeView: React.FC<{}> = ({ }) => { const [filterText, setFilterText] = React.useState(''); const [isShowingOutput, setIsShowingOutput] = React.useState(false); + const [outputContainsError, setOutputContainsError] = React.useState(false); const [statusFilters, setStatusFilters] = React.useState>(new Map([ ['passed', false], ['failed', false], @@ -134,6 +135,9 @@ export const UIModeView: React.FC<{}> = ({ } else { xtermDataSource.write(params.text!); } + + if (params.type === 'stderr') + setOutputContainsError(true); }), testServerConnection.onClose(() => setIsDisconnected(true)) ]; @@ -168,6 +172,7 @@ export const UIModeView: React.FC<{}> = ({ }, onError: error => { xtermDataSource.write((error.stack || error.value || '') + '\n'); + setOutputContainsError(true); }, pathSeparator: queryParams.pathSeparator, }); @@ -426,7 +431,7 @@ export const UIModeView: React.FC<{}> = ({
Output
- xtermDataSource.clear()}> + { xtermDataSource.clear(); setOutputContainsError(false); }}>
setIsShowingOutput(false)}>
@@ -447,7 +452,10 @@ export const UIModeView: React.FC<{}> = ({ Playwright logo
Playwright
reloadTests()} disabled={isRunningTest || isLoading}> - { setIsShowingOutput(!isShowingOutput); }} /> +
+ { setIsShowingOutput(!isShowingOutput); }} /> + {outputContainsError &&
} +
{!hasBrowsers && } { }); it('should work when passing the proxy only on the context level', async ({ browserName, platform, browserType, server, proxyServer }) => { - // Currently an upstream bug in the network stack of Chromium which leads that - // the wrong proxy gets used in the BrowserContext. - it.fixme(browserName === 'chromium' && platform === 'win32'); - proxyServer.forwardTo(server.PORT); let browser; try { diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 75ca2468f7..682df0b00f 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -76,15 +76,6 @@ const test = base.extend({ }, }); -test.use({ - launchOptions: async ({ launchOptions }, use) => { - await use({ - ...launchOptions, - proxy: { server: 'per-context' } - }); - } -}); - const kDummyFileName = __filename; const kValidationSubTests: [BrowserContextOptions, string][] = [ [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index a58a8a38b9..4320aac5e8 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -52,6 +52,46 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`) expect(message.text()).toBe('click'); }); + test('should double click', async ({ page, openRecorder }) => { + const recorder = await openRecorder(); + + await recorder.setContentAndWait(``); + + const locator = await recorder.hoverOverElement('button'); + expect(locator).toBe(`getByRole('button', { name: 'Submit' })`); + + const messages: string[] = []; + page.on('console', message => { + if (message.text().includes('click')) + messages.push(message.text()); + }); + const [, sources] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === 'dblclick 2'), + recorder.waitForOutput('JavaScript', 'dblclick'), + recorder.trustedDblclick(), + ]); + + expect.soft(sources.get('JavaScript')!.text).toContain(` + await page.getByRole('button', { name: 'Submit' }).dblclick();`); + + expect.soft(sources.get('Python')!.text).toContain(` + page.get_by_role("button", name="Submit").dblclick()`); + + expect.soft(sources.get('Python Async')!.text).toContain(` + await page.get_by_role("button", name="Submit").dblclick()`); + + expect.soft(sources.get('Java')!.text).toContain(` + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).dblclick()`); + + expect.soft(sources.get('C#')!.text).toContain(` +await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync();`); + + expect(messages).toEqual([ + 'click 1', + 'click 2', + 'dblclick 2', + ]); + }); test('should ignore programmatic events', async ({ page, openRecorder }) => { const recorder = await openRecorder(); diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index 6ebbc1fdd1..02acdeb6fd 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -191,6 +191,13 @@ class Recorder { await this.page.mouse.up(options); } + async trustedDblclick() { + await this.page.mouse.down(); + await this.page.mouse.up(); + await this.page.mouse.down({ clickCount: 2 }); + await this.page.mouse.up(); + } + async focusElement(selector: string): Promise { return this.waitForHighlight(() => this.page.focus(selector)); } diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 344763bb1d..947f3b8788 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -145,7 +145,6 @@ it('should authenticate', async ({ browserType, server }) => { }); it('should work with authenticate followed by redirect', async ({ browserName, browserType, server }) => { - it.fixme(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/10095'); function hasAuth(req, res) { const auth = req.headers['proxy-authorization']; if (!auth) { @@ -324,7 +323,6 @@ async function setupSocksForwardingServer(port: number, forwardPort: number) { } it('should use SOCKS proxy for websocket requests', async ({ browserName, platform, browserType, server }, testInfo) => { - it.fixme(browserName === 'webkit' && platform !== 'linux'); const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer(testInfo.workerIndex + 2048 + 2, server.PORT); const browser = await browserType.launch({ proxy: { diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index 63928e86fb..a541ad9c96 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -18,41 +18,6 @@ import path from 'path'; import { test, expect, parseTestRunnerOutput, stripAnsi } from './playwright-test-fixtures'; const { spawnAsync } = require('../../packages/playwright-core/lib/utils'); -test('should be able to call expect.extend in config', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'helper.ts': ` - import { test as base, expect } from '@playwright/test'; - expect.extend({ - toBeWithinRange(received, floor, ceiling) { - const pass = received >= floor && received <= ceiling; - if (pass) { - return { - message: () => - 'passed', - pass: true, - }; - } else { - return { - message: () => 'failed', - pass: false, - }; - } - }, - }); - export const test = base; - `, - 'expect-test.spec.ts': ` - import { test } from './helper'; - test('numeric ranges', () => { - test.expect(100).toBeWithinRange(90, 110); - test.expect(101).not.toBeWithinRange(0, 100); - }); - ` - }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); -}); - test('should not expand huge arrays', async ({ runInlineTest }) => { const result = await runInlineTest({ 'expect-test.spec.ts': ` @@ -1043,8 +1008,8 @@ test('should expose timeout to custom matchers', async ({ runInlineTest, runTSC test('should throw error when using .equals()', async ({ runInlineTest }) => { const result = await runInlineTest({ 'helper.ts': ` - import { test as base, expect } from '@playwright/test'; - expect.extend({ + import { test as base, expect as baseExpect } from '@playwright/test'; + export const expect = baseExpect.extend({ toBeWithinRange(received, floor, ceiling) { this.equals(1, 2); }, @@ -1052,10 +1017,10 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => { export const test = base; `, 'expect-test.spec.ts': ` - import { test } from './helper'; + import { test, expect } from './helper'; test('numeric ranges', () => { - test.expect(() => { - test.expect(100).toBeWithinRange(90, 110); + expect(() => { + expect(100).toBeWithinRange(90, 110); }).toThrowError('It looks like you are using custom expect matchers that are not compatible with Playwright. See https://aka.ms/playwright/expect-compatibility'); }); ` @@ -1063,3 +1028,44 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('expect.extend should be immutable', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'expect-test.spec.ts': ` + import { test, expect } from '@playwright/test'; + const expectFoo = expect.extend({ + toFoo() { + console.log('%%foo'); + return { pass: true }; + } + }); + const expectFoo2 = expect.extend({ + toFoo() { + console.log('%%foo2'); + return { pass: true }; + } + }); + const expectBar = expectFoo.extend({ + toBar() { + console.log('%%bar'); + return { pass: true }; + } + }); + test('logs', () => { + expect(expectFoo).not.toBe(expectFoo2); + expect(expectFoo).not.toBe(expectBar); + + expectFoo().toFoo(); + expectFoo2().toFoo(); + expectBar().toFoo(); + expectBar().toBar(); + }); + ` + }); + expect(result.outputLines).toEqual([ + 'foo', + 'foo2', + 'foo', + 'bar', + ]); +}); \ No newline at end of file diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index f7538de3e9..96539a4c88 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -311,7 +311,9 @@ test('should report custom expect steps', async ({ runInlineTest }) => { }; `, 'a.test.ts': ` - expect.extend({ + import { test, expect as baseExpect } from '@playwright/test'; + + const expect = baseExpect.extend({ toBeWithinRange(received, floor, ceiling) { const pass = received >= floor && received <= ceiling; if (pass) { @@ -338,7 +340,6 @@ test('should report custom expect steps', async ({ runInlineTest }) => { }, }); - import { test, expect } from '@playwright/test'; test('fail', async ({}) => { expect(15).toBeWithinRange(10, 20); await expect(1).toBeFailingAsync(22); @@ -349,8 +350,8 @@ test('should report custom expect steps', async ({ runInlineTest }) => { expect(result.exitCode).toBe(1); expect(result.output).toBe(` hook |Before Hooks -expect |expect.toBeWithinRange @ a.test.ts:31 -expect |expect.toBeFailingAsync @ a.test.ts:32 +expect |expect.toBeWithinRange @ a.test.ts:32 +expect |expect.toBeFailingAsync @ a.test.ts:33 expect |↪ error: Error: It fails! hook |After Hooks hook |Worker Cleanup