Merge branch 'main' into sharding-algorithm

This commit is contained in:
Mathias Leppich 2024-09-13 11:55:41 +02:00
commit 171c5e1eee
39 changed files with 916 additions and 306 deletions

View file

@ -1,6 +1,6 @@
# 🎭 Playwright # 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-129.0.6668.29-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![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) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-129.0.6668.42-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![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) ## [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 | | | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: | | :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->129.0.6668.29<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Chromium <!-- GEN:chromium-version -->129.0.6668.42<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->18.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit <!-- GEN:webkit-version -->18.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->130.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox <!-- GEN:firefox-version -->130.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

View file

@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements:
### Miscellaneous ### Miscellaneous
- The `mcr.microsoft.com/playwright-dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - 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. 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. - 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. - [`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`. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements:
### Miscellaneous ### Miscellaneous
- The `mcr.microsoft.com/playwright-java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - 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. 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. - 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. - [`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`. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements:
### Miscellaneous ### Miscellaneous
- The `mcr.microsoft.com/playwright-python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - 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. 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. - 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. - [`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`. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

@ -3,15 +3,15 @@
"browsers": [ "browsers": [
{ {
"name": "chromium", "name": "chromium",
"revision": "1134", "revision": "1135",
"installByDefault": true, "installByDefault": true,
"browserVersion": "129.0.6668.29" "browserVersion": "129.0.6668.42"
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1256", "revision": "1259",
"installByDefault": false, "installByDefault": false,
"browserVersion": "130.0.6695.0" "browserVersion": "130.0.6713.0"
}, },
{ {
"name": "firefox", "name": "firefox",
@ -42,7 +42,11 @@
{ {
"name": "ffmpeg", "name": "ffmpeg",
"revision": "1010", "revision": "1010",
"installByDefault": true "installByDefault": true,
"revisionOverrides": {
"mac12": "1010",
"mac12-arm64": "1010"
}
}, },
{ {
"name": "android", "name": "android",

View file

@ -348,10 +348,10 @@ type CaptureOptions = {
fullPage: boolean; 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); validateOptions(options);
const browserType = lookupBrowserType(options); const browserType = lookupBrowserType(options);
const launchOptions: LaunchOptions = { headless, executablePath }; const launchOptions: LaunchOptions = extraOptions;
if (options.channel) if (options.channel)
launchOptions.channel = options.channel as any; launchOptions.channel = options.channel as any;
launchOptions.handleSIGINT = false; 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 headful mode, use host device scale factor for things to look nice.
// In headless, keep things the way it works in Playwright by default. // In headless, keep things the way it works in Playwright by default.
// Assume high-dpi on MacOS. TODO: this is not perfect. // Assume high-dpi on MacOS. TODO: this is not perfect.
if (!headless) if (!extraOptions.headless)
contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1; contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1;
// Work around the WebKit GTK scrolling issue. // 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) { 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({ await context._enableRecorder({
language, language,
launchOptions, 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) { async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) {
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; 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' }); 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({ await context._enableRecorder({
language, language,
launchOptions, launchOptions,
@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) {
} }
async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) { 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); console.log('Navigating to ' + url);
const page = await openPage(context, url); const page = await openPage(context, url);
await waitForPage(page, captureOptions); 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) { async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
if (options.browser !== 'chromium') if (options.browser !== 'chromium')
throw new Error('PDF creation is only working with 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); console.log('Navigating to ' + url);
const page = await openPage(context, url); const page = await openPage(context, url);
await waitForPage(page, captureOptions); await waitForPage(page, captureOptions);

View file

@ -16,7 +16,7 @@
import type { BrowserContextOptions } from '../../../types/types'; import type { BrowserContextOptions } from '../../../types/types';
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './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 { escapeWithQuotes, asLocator } from '../../utils';
import { deviceDescriptors } from '../deviceDescriptors'; import { deviceDescriptors } from '../deviceDescriptors';
@ -112,7 +112,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
let method = 'Click'; let method = 'Click';
if (action.clickCount === 2) if (action.clickCount === 2)
method = 'DblClick'; method = 'DblClick';
const options = toClickOptions(action); const options = toClickOptionsForSourceCode(action);
if (!Object.entries(options).length) if (!Object.entries(options).length)
return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`; return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`;
const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options'); const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options');

View file

@ -17,7 +17,7 @@
import type { BrowserContextOptions } from '../../../types/types'; import type { BrowserContextOptions } from '../../../types/types';
import type * as types from '../types'; import type * as types from '../types';
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } 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 { deviceDescriptors } from '../deviceDescriptors';
import { JavaScriptFormatter } from './javascript'; import { JavaScriptFormatter } from './javascript';
import { escapeWithQuotes, asLocator } from '../../utils'; import { escapeWithQuotes, asLocator } from '../../utils';
@ -101,7 +101,7 @@ export class JavaLanguageGenerator implements LanguageGenerator {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2)
method = 'dblclick'; method = 'dblclick';
const options = toClickOptions(action); const options = toClickOptionsForSourceCode(action);
const optionsText = formatClickOptions(options); const optionsText = formatClickOptions(options);
return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`; return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`;
} }

View file

@ -16,7 +16,7 @@
import type { BrowserContextOptions } from '../../../types/types'; import type { BrowserContextOptions } from '../../../types/types';
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './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 { deviceDescriptors } from '../deviceDescriptors';
import { escapeWithQuotes, asLocator } from '../../utils'; import { escapeWithQuotes, asLocator } from '../../utils';
@ -85,7 +85,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2)
method = 'dblclick'; method = 'dblclick';
const options = toClickOptions(action); const options = toClickOptionsForSourceCode(action);
const optionsString = formatOptions(options, false); const optionsString = formatOptions(options, false);
return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`; return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`;
} }

View file

@ -69,13 +69,14 @@ export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModif
return result; return result;
} }
export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions {
const modifiers = toKeyboardModifiers(action.modifiers); const modifiers = toKeyboardModifiers(action.modifiers);
const options: types.MouseClickOptions = {}; const options: types.MouseClickOptions = {};
if (action.button !== 'left') if (action.button !== 'left')
options.button = action.button; options.button = action.button;
if (modifiers.length) if (modifiers.length)
options.modifiers = modifiers; options.modifiers = modifiers;
// Do not render clickCount === 2 for dblclick.
if (action.clickCount > 2) if (action.clickCount > 2)
options.clickCount = action.clickCount; options.clickCount = action.clickCount;
if (action.position) if (action.position)

View file

@ -16,7 +16,7 @@
import type { BrowserContextOptions } from '../../../types/types'; import type { BrowserContextOptions } from '../../../types/types';
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './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 { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils';
import { deviceDescriptors } from '../deviceDescriptors'; import { deviceDescriptors } from '../deviceDescriptors';
@ -94,7 +94,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2)
method = 'dblclick'; method = 'dblclick';
const options = toClickOptions(action); const options = toClickOptionsForSourceCode(action);
const optionsString = formatOptions(options, false); const optionsString = formatOptions(options, false);
return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`;
} }

View file

@ -110,7 +110,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Galaxy S5": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -121,7 +121,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S5 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -132,7 +132,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S8": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 740 "height": 740
@ -143,7 +143,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S8 landscape": { "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": { "viewport": {
"width": 740, "width": 740,
"height": 360 "height": 360
@ -154,7 +154,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S9+": { "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": { "viewport": {
"width": 320, "width": 320,
"height": 658 "height": 658
@ -165,7 +165,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S9+ landscape": { "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": { "viewport": {
"width": 658, "width": 658,
"height": 320 "height": 320
@ -176,7 +176,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy Tab S4": { "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": { "viewport": {
"width": 712, "width": 712,
"height": 1138 "height": 1138
@ -187,7 +187,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy Tab S4 landscape": { "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": { "viewport": {
"width": 1138, "width": 1138,
"height": 712 "height": 712
@ -1098,7 +1098,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"LG Optimus L70": { "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": { "viewport": {
"width": 384, "width": 384,
"height": 640 "height": 640
@ -1109,7 +1109,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"LG Optimus L70 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 384 "height": 384
@ -1120,7 +1120,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 550": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1131,7 +1131,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 550 landscape": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1142,7 +1142,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 950": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1153,7 +1153,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 950 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1164,7 +1164,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 10": { "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": { "viewport": {
"width": 800, "width": 800,
"height": 1280 "height": 1280
@ -1175,7 +1175,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 10 landscape": { "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": { "viewport": {
"width": 1280, "width": 1280,
"height": 800 "height": 800
@ -1186,7 +1186,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 4": { "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": { "viewport": {
"width": 384, "width": 384,
"height": 640 "height": 640
@ -1197,7 +1197,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 4 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 384 "height": 384
@ -1208,7 +1208,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1219,7 +1219,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1230,7 +1230,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5X": { "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": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1241,7 +1241,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5X landscape": { "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": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1252,7 +1252,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6": { "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": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1263,7 +1263,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6 landscape": { "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": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1274,7 +1274,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6P": { "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": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1285,7 +1285,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6P landscape": { "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": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1296,7 +1296,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 7": { "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": { "viewport": {
"width": 600, "width": 600,
"height": 960 "height": 960
@ -1307,7 +1307,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 7 landscape": { "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": { "viewport": {
"width": 960, "width": 960,
"height": 600 "height": 600
@ -1362,7 +1362,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Pixel 2": { "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": { "viewport": {
"width": 411, "width": 411,
"height": 731 "height": 731
@ -1373,7 +1373,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 landscape": { "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": { "viewport": {
"width": 731, "width": 731,
"height": 411 "height": 411
@ -1384,7 +1384,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 XL": { "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": { "viewport": {
"width": 411, "width": 411,
"height": 823 "height": 823
@ -1395,7 +1395,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 XL landscape": { "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": { "viewport": {
"width": 823, "width": 823,
"height": 411 "height": 411
@ -1406,7 +1406,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 3": { "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": { "viewport": {
"width": 393, "width": 393,
"height": 786 "height": 786
@ -1417,7 +1417,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 3 landscape": { "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": { "viewport": {
"width": 786, "width": 786,
"height": 393 "height": 393
@ -1428,7 +1428,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4": { "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": { "viewport": {
"width": 353, "width": 353,
"height": 745 "height": 745
@ -1439,7 +1439,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4 landscape": { "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": { "viewport": {
"width": 745, "width": 745,
"height": 353 "height": 353
@ -1450,7 +1450,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4a (5G)": { "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": { "screen": {
"width": 412, "width": 412,
"height": 892 "height": 892
@ -1465,7 +1465,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4a (5G) landscape": { "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": { "screen": {
"height": 892, "height": 892,
"width": 412 "width": 412
@ -1480,7 +1480,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 5": { "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": { "screen": {
"width": 393, "width": 393,
"height": 851 "height": 851
@ -1495,7 +1495,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 5 landscape": { "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": { "screen": {
"width": 851, "width": 851,
"height": 393 "height": 393
@ -1510,7 +1510,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 7": { "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": { "screen": {
"width": 412, "width": 412,
"height": 915 "height": 915
@ -1525,7 +1525,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 7 landscape": { "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": { "screen": {
"width": 915, "width": 915,
"height": 412 "height": 412
@ -1540,7 +1540,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Moto G4": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1551,7 +1551,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Moto G4 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1562,7 +1562,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Chrome HiDPI": { "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": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1577,7 +1577,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Edge HiDPI": { "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": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1622,7 +1622,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Desktop Chrome": { "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": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080
@ -1637,7 +1637,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Edge": { "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": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080

View file

@ -39,6 +39,7 @@ import type { Dialog } from '../dialog';
import type { ConsoleMessage } from '../console'; import type { ConsoleMessage } from '../console';
import { serializeError } from '../errors'; import { serializeError } from '../errors';
import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
import { RecorderApp } from '../recorder/recorderApp'; import { RecorderApp } from '../recorder/recorderApp';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel { export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
@ -292,7 +293,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
} }
async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> { async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> {
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) { async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) {

View file

@ -36,6 +36,7 @@ interface RecorderTool {
cursor(): string; cursor(): string;
cleanup?(): void; cleanup?(): void;
onClick?(event: MouseEvent): void; onClick?(event: MouseEvent): void;
onDblClick?(event: MouseEvent): void;
onContextMenu?(event: MouseEvent): void; onContextMenu?(event: MouseEvent): void;
onDragStart?(event: DragEvent): void; onDragStart?(event: DragEvent): void;
onInput?(event: Event): void; onInput?(event: Event): void;
@ -210,6 +211,7 @@ class RecordActionTool implements RecorderTool {
private _hoveredElement: HTMLElement | null = null; private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null; private _activeModel: HighlightModel | null = null;
private _expectProgrammaticKeyUp = false; private _expectProgrammaticKeyUp = false;
private _pendingClickAction: { action: actions.ClickAction, timeout: NodeJS.Timeout } | undefined;
constructor(recorder: Recorder) { constructor(recorder: Recorder) {
this._recorder = recorder; this._recorder = recorder;
@ -252,6 +254,38 @@ class RecordActionTool implements RecorderTool {
return; 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({ this._performAction({
name: 'click', name: 'click',
selector: this._hoveredModel!.selector, 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) { onContextMenu(event: MouseEvent) {
// the 'contextmenu' event is triggered by a right-click or equivalent action, // 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 // and it prevents the click event from firing for that action, so we always
@ -915,6 +961,10 @@ class Overlay {
} }
return false; return false;
} }
onDblClick(event: MouseEvent) {
return false;
}
} }
export class Recorder { export class Recorder {
@ -970,6 +1020,7 @@ export class Recorder {
this._listeners = [ this._listeners = [
addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true), 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, '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, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true),
addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true), addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true),
addEventListener(this.document, 'input', event => this._onInput(event), true), addEventListener(this.document, 'input', event => this._onInput(event), true),
@ -1043,6 +1094,16 @@ export class Recorder {
this._currentTool.onClick?.(event); 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) { private _onContextMenu(event: MouseEvent) {
if (!event.isTrusted) if (!event.isTrusted)
return; return;

View file

@ -26,14 +26,12 @@ import { type Language } from './codegen/types';
import { Debugger } from './debugger'; import { Debugger } from './debugger';
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder'; 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'; import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils';
const recorderSymbol = Symbol('recorderSymbol'); const recorderSymbol = Symbol('recorderSymbol');
export type RecorderAppFactory = (recorder: Recorder) => Promise<IRecorderApp>; export class Recorder implements InstrumentationListener, IRecorder {
export class Recorder implements InstrumentationListener {
private _context: BrowserContext; private _context: BrowserContext;
private _mode: Mode; private _mode: Mode;
private _highlightedSelector = ''; private _highlightedSelector = '';
@ -47,14 +45,14 @@ export class Recorder implements InstrumentationListener {
private _omitCallTracking = false; private _omitCallTracking = false;
private _currentLanguage: Language; private _currentLanguage: Language;
static showInspector(context: BrowserContext, recorderAppFactory: RecorderAppFactory) { static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
const params: channels.BrowserContextRecorderSupplementEnableParams = {}; const params: channels.BrowserContextRecorderSupplementEnableParams = {};
if (isUnderTest()) if (isUnderTest())
params.language = process.env.TEST_INSPECTOR_LANGUAGE; params.language = process.env.TEST_INSPECTOR_LANGUAGE;
Recorder.show(context, recorderAppFactory, params).catch(() => {}); Recorder.show(context, recorderAppFactory, params).catch(() => {});
} }
static show(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> { static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>; let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
if (!recorderPromise) { if (!recorderPromise) {
recorderPromise = Recorder._create(context, recorderAppFactory, params); recorderPromise = Recorder._create(context, recorderAppFactory, params);
@ -63,7 +61,7 @@ export class Recorder implements InstrumentationListener {
return recorderPromise; return recorderPromise;
} }
private static async _create(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> { private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
const recorder = new Recorder(context, params); const recorder = new Recorder(context, params);
const recorderApp = await recorderAppFactory(recorder); const recorderApp = await recorderAppFactory(recorder);
await recorder._install(recorderApp); await recorder._install(recorderApp);

View file

@ -10,3 +10,6 @@
../../utils/** ../../utils/**
../../utilsBundle.ts ../../utilsBundle.ts
../../zipBundle.ts ../../zipBundle.ts
[recorderInTraceViewer.ts]
../trace/viewer/traceViewer.ts

View file

@ -69,13 +69,13 @@ export class ContextRecorder extends EventEmitter {
// Make a copy of options to modify them later. // Make a copy of options to modify them later.
const languageGeneratorOptions: LanguageGeneratorOptions = { const languageGeneratorOptions: LanguageGeneratorOptions = {
browserName: context._browser.options.name, browserName: context._browser.options.name,
launchOptions: { headless: false, ...params.launchOptions }, launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined },
contextOptions: { ...params.contextOptions }, contextOptions: { ...params.contextOptions },
deviceName: params.device, deviceName: params.device,
saveStorage: params.saveStorage, saveStorage: params.saveStorage,
}; };
const collection = new RecorderCollection(params.mode === 'recording'); const collection = new RecorderCollection(this._pageAliases, params.mode === 'recording');
collection.on('change', () => { collection.on('change', () => {
this._recorderSources = []; this._recorderSources = [];
for (const languageGenerator of this._orderedLanguages) { 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. // First page is called page, others are called popup1, popup2, etc.
const frame = page.mainFrame(); const frame = page.mainFrame();
page.on('close', () => { page.on('close', () => {
this._collection.addAction({ this._collection.addRecordedAction({
frame: this._describeMainFrame(page), frame: this._describeMainFrame(page),
committed: true, committed: true,
action: { action: {
@ -185,7 +185,7 @@ export class ContextRecorder extends EventEmitter {
if (page.opener()) { if (page.opener()) {
this._onPopup(page.opener()!, page); this._onPopup(page.opener()!, page);
} else { } else {
this._collection.addAction({ this._collection.addRecordedAction({
frame: this._describeMainFrame(page), frame: this._describeMainFrame(page),
committed: true, committed: true,
action: { action: {
@ -236,14 +236,15 @@ export class ContextRecorder extends EventEmitter {
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
this._collection.willPerformAction(actionInContext); const callMetadata = await this._collection.willPerformAction(actionInContext);
const success = await performAction(this._pageAliases, actionInContext); if (!callMetadata)
if (success) { return;
this._collection.didPerformAction(actionInContext); 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); this._setCommittedAfterTimeout(actionInContext);
} else {
this._collection.performedActionFailed(actionInContext);
}
} }
private async _recordAction(frame: Frame, action: actions.Action) { private async _recordAction(frame: Frame, action: actions.Action) {
@ -260,7 +261,7 @@ export class ContextRecorder extends EventEmitter {
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
this._setCommittedAfterTimeout(actionInContext); this._setCommittedAfterTimeout(actionInContext);
this._collection.addAction(actionInContext); this._collection.addRecordedAction(actionInContext);
} }
private _setCommittedAfterTimeout(actionInContext: ActionInContext) { private _setCommittedAfterTimeout(actionInContext: ActionInContext) {

View file

@ -24,9 +24,9 @@ import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes';
import { isUnderTest } from '../../utils'; import { isUnderTest } from '../../utils';
import { mime } from '../../utilsBundle'; import { mime } from '../../utilsBundle';
import { syncLocalStorageWithSettings } from '../launchApp'; import { syncLocalStorageWithSettings } from '../launchApp';
import type { Recorder, RecorderAppFactory } from '../recorder';
import type { BrowserContext } from '../browserContext'; import type { BrowserContext } from '../browserContext';
import { launchApp } from '../launchApp'; import { launchApp } from '../launchApp';
import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend';
declare global { declare global {
interface Window { interface Window {
@ -42,16 +42,6 @@ declare global {
} }
} }
export interface IRecorderApp extends EventEmitter {
close(): Promise<void>;
setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>;
setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[]): Promise<void>;
}
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {} async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {} async setPaused(paused: boolean): Promise<void> {}
@ -65,9 +55,9 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
export class RecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp {
private _page: Page; private _page: Page;
readonly wsEndpoint: string | undefined; 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(); super();
this.setMaxListeners(0); this.setMaxListeners(0);
this._recorder = recorder; this._recorder = recorder;
@ -113,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html'); await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html');
} }
static factory(context: BrowserContext): RecorderAppFactory { static factory(context: BrowserContext): IRecorderAppFactory {
return async recorder => { return async recorder => {
if (process.env.PW_CODEGEN_NO_INSPECTOR) if (process.env.PW_CODEGEN_NO_INSPECTOR)
return new EmptyRecorderApp(); return new EmptyRecorderApp();
@ -121,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}; };
} }
private static async _open(recorder: Recorder, inspectedContext: BrowserContext): Promise<IRecorderApp> { private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise<IRecorderApp> {
const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage; const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage;
const headed = !!inspectedContext._browser.options.headful; const headed = !!inspectedContext._browser.options.headful;
const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true });

View file

@ -16,23 +16,28 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import type { Frame } from '../frames'; import type { Frame } from '../frames';
import type { Page } from '../page';
import type { Signal } from './recorderActions'; import type { Signal } from './recorderActions';
import type { ActionInContext } from '../codegen/types'; 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 { export class RecorderCollection extends EventEmitter {
private _currentAction: ActionInContext | null = null;
private _lastAction: ActionInContext | null = null; private _lastAction: ActionInContext | null = null;
private _actions: ActionInContext[] = []; private _actions: ActionInContext[] = [];
private _enabled: boolean; private _enabled: boolean;
private _pageAliases: Map<Page, string>;
constructor(enabled: boolean) { constructor(pageAliases: Map<Page, string>, enabled: boolean) {
super(); super();
this._enabled = enabled; this._enabled = enabled;
this._pageAliases = pageAliases;
this.restart(); this.restart();
} }
restart() { restart() {
this._currentAction = null;
this._lastAction = null; this._lastAction = null;
this._actions = []; this._actions = [];
this.emit('change'); this.emit('change');
@ -46,60 +51,63 @@ export class RecorderCollection extends EventEmitter {
this._enabled = enabled; this._enabled = enabled;
} }
addAction(action: ActionInContext) { async willPerformAction(actionInContext: ActionInContext): Promise<CallMetadata | null> {
if (!this._enabled) if (!this._enabled)
return; return null;
this.willPerformAction(action); const { callMetadata, mainFrame } = this._callMetadataForAction(actionInContext);
this.didPerformAction(action); await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
this._lastAction = actionInContext;
return callMetadata;
} }
willPerformAction(action: ActionInContext) { private _callMetadataForAction(actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
if (!this._enabled) const mainFrame = mainFrameForAction(this._pageAliases, actionInContext);
return; const { action } = actionInContext;
this._currentAction = action; 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) if (!this._enabled)
return; 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) if (!this._enabled)
return; return;
const action = actionInContext.action; const action = actionInContext.action;
let eraseLastAction = false;
if (this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias) { const lastAction = this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias ? this._lastAction.action : undefined;
const lastAction = this._lastAction.action; if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate' && action.url === lastAction.url) {
// 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. // Already at a target URL.
this._currentAction = null;
return; return;
} }
}
// Check and uncheck erase click. if (lastAction && action.name === 'fill' && lastAction.name === 'fill' && action.selector === lastAction.selector)
if (lastAction && (action.name === 'check' || action.name === 'uncheck') && lastAction.name === 'click') { this._actions.pop();
if (action.selector === lastAction.selector)
eraseLastAction = true;
}
}
this._lastAction = actionInContext; this._lastAction = actionInContext;
this._currentAction = null;
if (eraseLastAction)
this._actions.pop();
this._actions.push(actionInContext); this._actions.push(actionInContext);
this.emit('change'); this.emit('change');
} }
@ -116,25 +124,14 @@ export class RecorderCollection extends EventEmitter {
if (!this._enabled) if (!this._enabled)
return; return;
// Signal either arrives while action is being performed or shortly after. if (this._lastAction && !this._lastAction.committed) {
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;
this._lastAction.action.signals.push(signal); this._lastAction.action.signals.push(signal);
this.emit('change'); this.emit('change');
return; return;
} }
if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { if (signal.name === 'navigation' && frame._page.mainFrame() === frame) {
this.addAction({ this.addRecordedAction({
frame: { frame: {
pageAlias, pageAlias,
framePath: [], framePath: [],

View file

@ -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<void>;
setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>;
setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[]): Promise<void>;
}
export type IRecorderAppFactory = (recorder: IRecorder) => Promise<IRecorderApp>;

View file

@ -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<void> {
this._transport.sendEvent?.('close', {});
}
async setPaused(paused: boolean): Promise<void> {
this._transport.sendEvent?.('setPaused', { paused });
}
async setMode(mode: Mode): Promise<void> {
this._transport.sendEvent?.('setMode', { mode });
}
async setFileIfNeeded(file: string): Promise<void> {
this._transport.sendEvent?.('setFileIfNeeded', { file });
}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {
this._transport.sendEvent?.('setSelector', { selector, userGesture });
}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {
this._transport.sendEvent?.('updateCallLogs', { callLogs });
}
async setSources(sources: Source[]): Promise<void> {
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;
}

View file

@ -14,115 +14,130 @@
* limitations under the License. * limitations under the License.
*/ */
import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils'; import { serializeExpectedTextValues } from '../../utils';
import { toClickOptions, toKeyboardModifiers } from '../codegen/language'; import { toKeyboardModifiers } from '../codegen/language';
import type { ActionInContext } from '../codegen/types'; import type { ActionInContext } from '../codegen/types';
import type { Frame } from '../frames';
import type { CallMetadata } from '../instrumentation'; import type { CallMetadata } from '../instrumentation';
import type { Page } from '../page'; 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<any>): Promise<boolean> { export async function performAction(callMetadata: CallMetadata, pageAliases: Map<Page, string>, actionInContext: ActionInContext) {
const callMetadata: CallMetadata = { const mainFrame = mainFrameForAction(pageAliases, actionInContext);
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<Page, string>, actionInContext: ActionInContext): Promise<boolean> {
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();
const { action } = actionInContext; const { action } = actionInContext;
const kActionTimeout = 5000; const kActionTimeout = 5000;
if (action.name === 'navigate') if (action.name === 'navigate') {
return await innerPerformAction(mainFrame, 'goto', { url: action.url }, callMetadata => mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout })); await mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout });
return;
}
if (action.name === 'openPage') if (action.name === 'openPage')
throw Error('Not reached'); 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); const selector = buildFullSelector(actionInContext.frame.framePath, action.selector);
if (action.name === 'click') { if (action.name === 'click') {
const options = toClickOptions(action); 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') { if (action.name === 'press') {
const modifiers = toKeyboardModifiers(action.modifiers); const modifiers = toKeyboardModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+'); 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 === 'fill') {
if (action.name === 'setInputFiles') await mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true });
return await innerPerformAction(mainFrame, 'setInputFiles', { selector, files: action.files }, callMetadata => mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true })); return;
if (action.name === 'check') }
return await innerPerformAction(mainFrame, 'check', { selector }, callMetadata => mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true }));
if (action.name === 'uncheck') if (action.name === 'setInputFiles') {
return await innerPerformAction(mainFrame, 'uncheck', { selector }, callMetadata => mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true })); 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') { if (action.name === 'select') {
const values = action.options.map(value => ({ value })); 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') { if (action.name === 'assertChecked') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.be.checked', expression: 'to.be.checked',
isNot: !action.checked, isNot: !action.checked,
timeout: kActionTimeout, timeout: kActionTimeout,
})); });
return;
} }
if (action.name === 'assertText') { if (action.name === 'assertText') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.have.text', expression: 'to.have.text',
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
isNot: false, isNot: false,
timeout: kActionTimeout, timeout: kActionTimeout,
})); });
return;
} }
if (action.name === 'assertValue') { if (action.name === 'assertValue') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.have.value', expression: 'to.have.value',
expectedValue: action.value, expectedValue: action.value,
isNot: false, isNot: false,
timeout: kActionTimeout, timeout: kActionTimeout,
})); });
return;
} }
if (action.name === 'assertVisible') { if (action.name === 'assertVisible') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.be.visible', expression: 'to.be.visible',
isNot: false, isNot: false,
timeout: kActionTimeout, timeout: kActionTimeout,
})); });
return;
} }
throw new Error('Internal error: unexpected action ' + (action as any).name); 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;
}

View file

@ -20,6 +20,8 @@ import type { Page } from '../page';
import type { ActionInContext } from '../codegen/types'; import type { ActionInContext } from '../codegen/types';
import type { Frame } from '../frames'; import type { Frame } from '../frames';
import type * as actions from './recorderActions'; import type * as actions from './recorderActions';
import { toKeyboardModifiers } from '../codegen/language';
import { serializeExpectedTextValues } from '../../utils/expectUtils';
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
let title = metadata.apiName || metadata.method; let title = metadata.apiName || metadata.method;
@ -72,3 +74,58 @@ export async function frameForAction(pageAliases: Map<Page, string>, actionInCon
throw new Error('Internal error: frame not found'); throw new Error('Internal error: frame not found');
return result.frame; 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,
};
}
}
}

View file

@ -16,6 +16,7 @@
import { import {
captureRawStack, captureRawStack,
createGuid,
isString, isString,
pollAgainstDeadline } from 'playwright-core/lib/utils'; pollAgainstDeadline } from 'playwright-core/lib/utils';
import type { ExpectZone } 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 }; type ExpectMessage = string | { message?: string };
function createMatchers(actual: unknown, info: ExpectMetaInfo): any { function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]): any {
return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info)); 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<string, Function>) {
const expectInstance: Expect<{}> = new Proxy(expectLibrary, { const expectInstance: Expect<{}> = new Proxy(expectLibrary, {
apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) {
const [actual, messageOrOptions] = argumentsList; const [actual, messageOrOptions] = argumentsList;
@ -119,18 +126,22 @@ function createExpect(info: ExpectMetaInfo) {
throw new Error('`expect.poll()` accepts only function as a first argument'); throw new Error('`expect.poll()` accepts only function as a first argument');
newInfo.generator = actual as any; 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') if (property === 'configure')
return configure; return configure;
if (property === 'extend') { if (property === 'extend') {
return (matchers: any) => { return (matchers: any) => {
const qualifier = [...prefix, createGuid()];
const wrappedMatchers: any = {}; const wrappedMatchers: any = {};
const extendedMatchers: any = { ...customMatchers };
for (const [name, matcher] of Object.entries(matchers)) { 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 { isNot, promise, utils } = this;
const newThis: ExpectMatcherState = { const newThis: ExpectMatcherState = {
isNot, isNot,
@ -141,9 +152,12 @@ function createExpect(info: ExpectMetaInfo) {
(newThis as any).equals = throwUnsupportedExpectMatcherError; (newThis as any).equals = throwUnsupportedExpectMatcherError;
return (matcher as any).call(newThis, ...args); return (matcher as any).call(newThis, ...args);
}; };
Object.defineProperty(wrappedMatchers[key], 'name', { value: name });
extendedMatchers[name] = wrappedMatchers[key];
} }
expectLibrary.extend(wrappedMatchers); 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') { if (property === 'poll') {
return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => {
const poll = isString(messageOrOptions) ? {} : messageOrOptions || {}; const poll = isString(messageOrOptions) ? {} : messageOrOptions || {};
@ -178,7 +195,7 @@ function createExpect(info: ExpectMetaInfo) {
newInfo.pollIntervals = configuration._poll.intervals; newInfo.pollIntervals = configuration._poll.intervals;
} }
} }
return createExpect(newInfo); return createExpect(newInfo, prefix, customMatchers);
}; };
return expectInstance; return expectInstance;
@ -241,15 +258,28 @@ type ExpectMetaInfo = {
class ExpectMetaInfoProxyHandler implements ProxyHandler<any> { class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
private _info: ExpectMetaInfo; private _info: ExpectMetaInfo;
private _prefix: string[];
constructor(info: ExpectMetaInfo) { constructor(info: ExpectMetaInfo, prefix: string[]) {
this._info = { ...info }; this._info = { ...info };
this._prefix = prefix;
} }
get(target: Object, matcherName: string | symbol, receiver: any): any { get(target: Object, matcherName: string | symbol, receiver: any): any {
let matcher = Reflect.get(target, matcherName, receiver); let matcher = Reflect.get(target, matcherName, receiver);
if (typeof matcherName !== 'string') if (typeof matcherName !== 'string')
return matcher; 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) if (matcher === undefined)
throw new Error(`expect: Property '${matcherName}' not found.`); throw new Error(`expect: Property '${matcherName}' not found.`);
if (typeof matcher !== 'function') { if (typeof matcher !== 'function') {
@ -260,7 +290,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
if (this._info.isPoll) { if (this._info.isPoll) {
if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects') if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects')
throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`); 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[]) => { return (...args: any[]) => {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
@ -320,7 +350,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
} }
} }
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 testInfo = currentTestInfo();
const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); 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) if (isNot)
expectInstance = expectInstance.not; expectInstance = expectInstance.not;
try { try {
expectInstance[matcherName].call(expectInstance, ...args); expectInstance[qualifiedMatcherName].call(expectInstance, ...args);
return { continuePolling: false, result: undefined }; return { continuePolling: false, result: undefined };
} catch (error) { } catch (error) {
return { continuePolling: true, result: error }; return { continuePolling: true, result: error };
@ -375,8 +405,15 @@ function computeArgsSuffix(matcherName: string, args: any[]) {
return value ? `(${value})` : ''; return value ? `(${value})` : '';
} }
export const expect: Expect<{}> = createExpect({}).extend(customMatchers); export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers);
export function mergeExpects(...expects: any[]) { 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;
} }

View file

@ -0,0 +1,28 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/playwright-logo.svg" type="image/svg+xml">
<title>Playwright Recorder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/recorder.tsx"></script>
</body>
</html>

View file

@ -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<void>(f => {
navigator.serviceWorker.oncontrollerchange = () => f();
});
}
// Keep SW running.
setInterval(function() { fetch('ping'); }, 10000);
}
ReactDOM.createRoot(document.querySelector('#root')!).render(<RecorderView />);
})();

View file

@ -16,6 +16,7 @@
.network-request-details-tab { .network-request-details-tab {
width: 100%; width: 100%;
height: 100%;
user-select: text; user-select: text;
line-height: 24px; line-height: 24px;
margin-left: 10px; margin-left: 10px;

View file

@ -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.
*/

View file

@ -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<Connection | null>(null);
const [sources, setSources] = React.useState<Source[]>([]);
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 <div className='vbox workbench-loader'>
<TraceView
traceLocation={trace}
sources={sources} />
</div>;
};
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<NodeJS.Timeout | null>(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 <Workbench
key='workbench'
model={model?.model}
showSourcesFirst={true}
fallbackLocation={fallbackLocation}
isLive={true}
/>;
};
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
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<number, { resolve: (arg: any) => 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<any> {
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);
}
}
}

View file

@ -55,7 +55,7 @@ export const SourceTab: React.FunctionComponent<{
let source = sources.get(file); let source = sources.get(file);
// Fallback location can fall outside the sources model. // Fallback location can fall outside the sources model.
if (!source) { if (!source) {
source = { errors: fallbackLocation?.source?.errors || [], content: undefined }; source = { errors: fallbackLocation?.source?.errors || [], content: fallbackLocation?.source?.content };
sources.set(file, source); sources.set(file, source);
} }
@ -66,7 +66,9 @@ export const SourceTab: React.FunctionComponent<{
highlight.push({ line: targetLine, type: 'running' }); highlight.push({ line: targetLine, type: 'running' });
// After the source update, but before the test run, don't trust the cache. // 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); const sha1 = await calculateSha1(file);
try { try {
let response = await fetch(`sha1/src@${sha1}.txt`); let response = await fetch(`sha1/src@${sha1}.txt`);

View file

@ -72,6 +72,7 @@ export const UIModeView: React.FC<{}> = ({
}) => { }) => {
const [filterText, setFilterText] = React.useState<string>(''); const [filterText, setFilterText] = React.useState<string>('');
const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(false); const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(false);
const [outputContainsError, setOutputContainsError] = React.useState(false);
const [statusFilters, setStatusFilters] = React.useState<Map<string, boolean>>(new Map([ const [statusFilters, setStatusFilters] = React.useState<Map<string, boolean>>(new Map([
['passed', false], ['passed', false],
['failed', false], ['failed', false],
@ -134,6 +135,9 @@ export const UIModeView: React.FC<{}> = ({
} else { } else {
xtermDataSource.write(params.text!); xtermDataSource.write(params.text!);
} }
if (params.type === 'stderr')
setOutputContainsError(true);
}), }),
testServerConnection.onClose(() => setIsDisconnected(true)) testServerConnection.onClose(() => setIsDisconnected(true))
]; ];
@ -168,6 +172,7 @@ export const UIModeView: React.FC<{}> = ({
}, },
onError: error => { onError: error => {
xtermDataSource.write((error.stack || error.value || '') + '\n'); xtermDataSource.write((error.stack || error.value || '') + '\n');
setOutputContainsError(true);
}, },
pathSeparator: queryParams.pathSeparator, pathSeparator: queryParams.pathSeparator,
}); });
@ -426,7 +431,7 @@ export const UIModeView: React.FC<{}> = ({
<div className={clsx('vbox', !isShowingOutput && 'hidden')}> <div className={clsx('vbox', !isShowingOutput && 'hidden')}>
<Toolbar> <Toolbar>
<div className='section-title' style={{ flex: 'none' }}>Output</div> <div className='section-title' style={{ flex: 'none' }}>Output</div>
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton> <ToolbarButton icon='circle-slash' title='Clear output' onClick={() => { xtermDataSource.clear(); setOutputContainsError(false); }}></ToolbarButton>
<div className='spacer'></div> <div className='spacer'></div>
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton> <ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
</Toolbar> </Toolbar>
@ -447,7 +452,10 @@ export const UIModeView: React.FC<{}> = ({
<img src='playwright-logo.svg' alt='Playwright logo' /> <img src='playwright-logo.svg' alt='Playwright logo' />
<div className='section-title'>Playwright</div> <div className='section-title'>Playwright</div>
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton> <ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='terminal' title={'Toggle output — ' + (isMac ? '⌃`' : 'Ctrl + `')} toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} /> <div style={{ position: 'relative' }}>
<ToolbarButton icon={'terminal'} title={'Toggle output — ' + (isMac ? '⌃`' : 'Ctrl + `')} toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
{outputContainsError && <div title='Output contains error' style={{ position: 'absolute', top: 2, right: 2, width: 7, height: 7, borderRadius: '50%', backgroundColor: 'var(--vscode-notificationsErrorIcon-foreground)' }} />}
</div>
{!hasBrowsers && <ToolbarButton icon='lightbulb-autofix' style={{ color: 'var(--vscode-list-warningForeground)' }} title='Playwright browsers are missing' onClick={openInstallDialog} />} {!hasBrowsers && <ToolbarButton icon='lightbulb-autofix' style={{ color: 'var(--vscode-list-warningForeground)' }} title='Playwright browsers are missing' onClick={openInstallDialog} />}
</Toolbar> </Toolbar>
<FiltersView <FiltersView

View file

@ -45,6 +45,7 @@ export default defineConfig({
index: path.resolve(__dirname, 'index.html'), index: path.resolve(__dirname, 'index.html'),
uiMode: path.resolve(__dirname, 'uiMode.html'), uiMode: path.resolve(__dirname, 'uiMode.html'),
embedded: path.resolve(__dirname, 'embedded.html'), embedded: path.resolve(__dirname, 'embedded.html'),
recorder: path.resolve(__dirname, 'recorder.html'),
snapshot: path.resolve(__dirname, 'snapshot.html'), snapshot: path.resolve(__dirname, 'snapshot.html'),
}, },
output: { output: {

View file

@ -25,10 +25,6 @@ it.beforeEach(({ server }) => {
}); });
it('should work when passing the proxy only on the context level', async ({ browserName, platform, browserType, server, proxyServer }) => { 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); proxyServer.forwardTo(server.PORT);
let browser; let browser;
try { try {

View file

@ -76,15 +76,6 @@ const test = base.extend<TestOptions>({
}, },
}); });
test.use({
launchOptions: async ({ launchOptions }, use) => {
await use({
...launchOptions,
proxy: { server: 'per-context' }
});
}
});
const kDummyFileName = __filename; const kDummyFileName = __filename;
const kValidationSubTests: [BrowserContextOptions, string][] = [ const kValidationSubTests: [BrowserContextOptions, string][] = [
[{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'],

View file

@ -52,6 +52,46 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`)
expect(message.text()).toBe('click'); expect(message.text()).toBe('click');
}); });
test('should double click', async ({ page, openRecorder }) => {
const recorder = await openRecorder();
await recorder.setContentAndWait(`<button onclick="console.log('click ' + event.detail)" ondblclick="console.log('dblclick ' + event.detail)">Submit</button>`);
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 }) => { test('should ignore programmatic events', async ({ page, openRecorder }) => {
const recorder = await openRecorder(); const recorder = await openRecorder();

View file

@ -191,6 +191,13 @@ class Recorder {
await this.page.mouse.up(options); 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<string> { async focusElement(selector: string): Promise<string> {
return this.waitForHighlight(() => this.page.focus(selector)); return this.waitForHighlight(() => this.page.focus(selector));
} }

View file

@ -145,7 +145,6 @@ it('should authenticate', async ({ browserType, server }) => {
}); });
it('should work with authenticate followed by redirect', async ({ browserName, 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) { function hasAuth(req, res) {
const auth = req.headers['proxy-authorization']; const auth = req.headers['proxy-authorization'];
if (!auth) { 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('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 { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer(testInfo.workerIndex + 2048 + 2, server.PORT);
const browser = await browserType.launch({ const browser = await browserType.launch({
proxy: { proxy: {

View file

@ -18,41 +18,6 @@ import path from 'path';
import { test, expect, parseTestRunnerOutput, stripAnsi } from './playwright-test-fixtures'; import { test, expect, parseTestRunnerOutput, stripAnsi } from './playwright-test-fixtures';
const { spawnAsync } = require('../../packages/playwright-core/lib/utils'); 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 }) => { test('should not expand huge arrays', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'expect-test.spec.ts': ` '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 }) => { test('should throw error when using .equals()', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'helper.ts': ` 'helper.ts': `
import { test as base, expect } from '@playwright/test'; import { test as base, expect as baseExpect } from '@playwright/test';
expect.extend({ export const expect = baseExpect.extend({
toBeWithinRange(received, floor, ceiling) { toBeWithinRange(received, floor, ceiling) {
this.equals(1, 2); this.equals(1, 2);
}, },
@ -1052,10 +1017,10 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => {
export const test = base; export const test = base;
`, `,
'expect-test.spec.ts': ` 'expect-test.spec.ts': `
import { test } from './helper'; import { test, expect } from './helper';
test('numeric ranges', () => { test('numeric ranges', () => {
test.expect(() => { expect(() => {
test.expect(100).toBeWithinRange(90, 110); 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'); }).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.exitCode).toBe(0);
expect(result.passed).toBe(1); 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',
]);
});

View file

@ -311,7 +311,9 @@ test('should report custom expect steps', async ({ runInlineTest }) => {
}; };
`, `,
'a.test.ts': ` 'a.test.ts': `
expect.extend({ import { test, expect as baseExpect } from '@playwright/test';
const expect = baseExpect.extend({
toBeWithinRange(received, floor, ceiling) { toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling; const pass = received >= floor && received <= ceiling;
if (pass) { if (pass) {
@ -338,7 +340,6 @@ test('should report custom expect steps', async ({ runInlineTest }) => {
}, },
}); });
import { test, expect } from '@playwright/test';
test('fail', async ({}) => { test('fail', async ({}) => {
expect(15).toBeWithinRange(10, 20); expect(15).toBeWithinRange(10, 20);
await expect(1).toBeFailingAsync(22); await expect(1).toBeFailingAsync(22);
@ -349,8 +350,8 @@ test('should report custom expect steps', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.output).toBe(` expect(result.output).toBe(`
hook |Before Hooks hook |Before Hooks
expect |expect.toBeWithinRange @ a.test.ts:31 expect |expect.toBeWithinRange @ a.test.ts:32
expect |expect.toBeFailingAsync @ a.test.ts:32 expect |expect.toBeFailingAsync @ a.test.ts:33
expect | error: Error: It fails! expect | error: Error: It fails!
hook |After Hooks hook |After Hooks
hook |Worker Cleanup hook |Worker Cleanup