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
[![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)
@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr
| | 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: |
| 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
- The `mcr.microsoft.com/playwright-dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-dotnet:v1.47.0-jammy` instead.
- The `mcr.microsoft.com/playwright/dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/dotnet:v1.47.0-jammy` instead.
- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility.
- TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths.
- [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated.
- We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements:
### Miscellaneous
- The `mcr.microsoft.com/playwright-java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright-java:v1.47.0-jammy` instead.
- The `mcr.microsoft.com/playwright/java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright/java:v1.47.0-jammy` instead.
- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility.
- TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths.
- [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated.
- We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements:
### Miscellaneous
- The `mcr.microsoft.com/playwright-python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-python:v1.47.0-jammy` instead.
- The `mcr.microsoft.com/playwright/python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/python:v1.47.0-jammy` instead.
- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility.
- TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as bytes instead of file paths.
- [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated.
- We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

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

View file

@ -348,10 +348,10 @@ type CaptureOptions = {
fullPage: boolean;
};
async function launchContext(options: Options, headless: boolean, executablePath?: string): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> {
async function launchContext(options: Options, extraOptions: LaunchOptions): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> {
validateOptions(options);
const browserType = lookupBrowserType(options);
const launchOptions: LaunchOptions = { headless, executablePath };
const launchOptions: LaunchOptions = extraOptions;
if (options.channel)
launchOptions.channel = options.channel as any;
launchOptions.handleSIGINT = false;
@ -363,7 +363,7 @@ async function launchContext(options: Options, headless: boolean, executablePath
// In headful mode, use host device scale factor for things to look nice.
// In headless, keep things the way it works in Playwright by default.
// Assume high-dpi on MacOS. TODO: this is not perfect.
if (!headless)
if (!extraOptions.headless)
contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1;
// Work around the WebKit GTK scrolling issue.
@ -547,7 +547,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi
}
async function open(options: Options, url: string | undefined, language: string) {
const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH);
const { context, launchOptions, contextOptions } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH });
await context._enableRecorder({
language,
launchOptions,
@ -560,8 +560,17 @@ async function open(options: Options, url: string | undefined, language: string)
async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) {
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options;
const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH);
const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`);
const { context, launchOptions, contextOptions } = await launchContext(options, {
headless: !!process.env.PWTEST_CLI_HEADLESS,
executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH,
tracesDir,
});
dotenv.config({ path: 'playwright.env' });
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
await fs.promises.mkdir(tracesDir, { recursive: true });
await context.tracing.start({ name: 'trace', _live: true });
}
await context._enableRecorder({
language,
launchOptions,
@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) {
}
async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
const { context } = await launchContext(options, true);
const { context } = await launchContext(options, { headless: true });
console.log('Navigating to ' + url);
const page = await openPage(context, url);
await waitForPage(page, captureOptions);
@ -600,7 +609,7 @@ async function screenshot(options: Options, captureOptions: CaptureOptions, url:
async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
if (options.browser !== 'chromium')
throw new Error('PDF creation is only working with Chromium');
const { context } = await launchContext({ ...options, browser: 'chromium' }, true);
const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true });
console.log('Navigating to ' + url);
const page = await openPage(context, url);
await waitForPage(page, captureOptions);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -110,7 +110,7 @@
"defaultBrowserType": "webkit"
},
"Galaxy S5": {
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 360,
"height": 640
@ -121,7 +121,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 360
@ -132,7 +132,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S8": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 360,
"height": 740
@ -143,7 +143,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S8 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 740,
"height": 360
@ -154,7 +154,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S9+": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 320,
"height": 658
@ -165,7 +165,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S9+ landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 658,
"height": 320
@ -176,7 +176,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy Tab S4": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36",
"viewport": {
"width": 712,
"height": 1138
@ -187,7 +187,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy Tab S4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36",
"viewport": {
"width": 1138,
"height": 712
@ -1098,7 +1098,7 @@
"defaultBrowserType": "webkit"
},
"LG Optimus L70": {
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 384,
"height": 640
@ -1109,7 +1109,7 @@
"defaultBrowserType": "chromium"
},
"LG Optimus L70 landscape": {
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 384
@ -1120,7 +1120,7 @@
"defaultBrowserType": "chromium"
},
"Microsoft Lumia 550": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263",
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263",
"viewport": {
"width": 640,
"height": 360
@ -1131,7 +1131,7 @@
"defaultBrowserType": "chromium"
},
"Microsoft Lumia 550 landscape": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263",
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263",
"viewport": {
"width": 360,
"height": 640
@ -1142,7 +1142,7 @@
"defaultBrowserType": "chromium"
},
"Microsoft Lumia 950": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263",
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263",
"viewport": {
"width": 360,
"height": 640
@ -1153,7 +1153,7 @@
"defaultBrowserType": "chromium"
},
"Microsoft Lumia 950 landscape": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263",
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263",
"viewport": {
"width": 640,
"height": 360
@ -1164,7 +1164,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 10": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36",
"viewport": {
"width": 800,
"height": 1280
@ -1175,7 +1175,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 10 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36",
"viewport": {
"width": 1280,
"height": 800
@ -1186,7 +1186,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 4": {
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 384,
"height": 640
@ -1197,7 +1197,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 384
@ -1208,7 +1208,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 5": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 360,
"height": 640
@ -1219,7 +1219,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 360
@ -1230,7 +1230,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 5X": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 412,
"height": 732
@ -1241,7 +1241,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 5X landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 732,
"height": 412
@ -1252,7 +1252,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 6": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 412,
"height": 732
@ -1263,7 +1263,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 6 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 732,
"height": 412
@ -1274,7 +1274,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 6P": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 412,
"height": 732
@ -1285,7 +1285,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 6P landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 732,
"height": 412
@ -1296,7 +1296,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 7": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36",
"viewport": {
"width": 600,
"height": 960
@ -1307,7 +1307,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 7 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36",
"viewport": {
"width": 960,
"height": 600
@ -1362,7 +1362,7 @@
"defaultBrowserType": "webkit"
},
"Pixel 2": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 411,
"height": 731
@ -1373,7 +1373,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 2 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 731,
"height": 411
@ -1384,7 +1384,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 2 XL": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 411,
"height": 823
@ -1395,7 +1395,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 2 XL landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 823,
"height": 411
@ -1406,7 +1406,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 3": {
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 393,
"height": 786
@ -1417,7 +1417,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 3 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 786,
"height": 393
@ -1428,7 +1428,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 4": {
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 353,
"height": 745
@ -1439,7 +1439,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 745,
"height": 353
@ -1450,7 +1450,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 4a (5G)": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"screen": {
"width": 412,
"height": 892
@ -1465,7 +1465,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 4a (5G) landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"screen": {
"height": 892,
"width": 412
@ -1480,7 +1480,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 5": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"screen": {
"width": 393,
"height": 851
@ -1495,7 +1495,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"screen": {
"width": 851,
"height": 393
@ -1510,7 +1510,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 7": {
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"screen": {
"width": 412,
"height": 915
@ -1525,7 +1525,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 7 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"screen": {
"width": 915,
"height": 412
@ -1540,7 +1540,7 @@
"defaultBrowserType": "chromium"
},
"Moto G4": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 360,
"height": 640
@ -1551,7 +1551,7 @@
"defaultBrowserType": "chromium"
},
"Moto G4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 360
@ -1562,7 +1562,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Chrome HiDPI": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36",
"screen": {
"width": 1792,
"height": 1120
@ -1577,7 +1577,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Edge HiDPI": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36 Edg/129.0.6668.29",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42",
"screen": {
"width": 1792,
"height": 1120
@ -1622,7 +1622,7 @@
"defaultBrowserType": "webkit"
},
"Desktop Chrome": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36",
"screen": {
"width": 1920,
"height": 1080
@ -1637,7 +1637,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Edge": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36 Edg/129.0.6668.29",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42",
"screen": {
"width": 1920,
"height": 1080

View file

@ -39,6 +39,7 @@ import type { Dialog } from '../dialog';
import type { ConsoleMessage } from '../console';
import { serializeError } from '../errors';
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
import { RecorderApp } from '../recorder/recorderApp';
export class BrowserContextDispatcher extends Dispatcher<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> {
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) {

View file

@ -36,6 +36,7 @@ interface RecorderTool {
cursor(): string;
cleanup?(): void;
onClick?(event: MouseEvent): void;
onDblClick?(event: MouseEvent): void;
onContextMenu?(event: MouseEvent): void;
onDragStart?(event: DragEvent): void;
onInput?(event: Event): void;
@ -210,6 +211,7 @@ class RecordActionTool implements RecorderTool {
private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null;
private _expectProgrammaticKeyUp = false;
private _pendingClickAction: { action: actions.ClickAction, timeout: NodeJS.Timeout } | undefined;
constructor(recorder: Recorder) {
this._recorder = recorder;
@ -252,6 +254,38 @@ class RecordActionTool implements RecorderTool {
return;
}
this._cancelPendingClickAction();
// Stall click in case we are observing double-click.
if (event.detail === 1) {
this._pendingClickAction = {
action: {
name: 'click',
selector: this._hoveredModel!.selector,
position: positionForEvent(event),
signals: [],
button: buttonForEvent(event),
modifiers: modifiersForEvent(event),
clickCount: event.detail
},
timeout: setTimeout(() => this._commitPendingClickAction(), 200)
};
}
}
onDblClick(event: MouseEvent) {
if (isRangeInput(this._hoveredElement))
return;
if (this._shouldIgnoreMouseEvent(event))
return;
// Only allow double click dispatch while action is in progress.
if (this._actionInProgress(event))
return;
if (this._consumedDueToNoModel(event, this._hoveredModel))
return;
this._cancelPendingClickAction();
this._performAction({
name: 'click',
selector: this._hoveredModel!.selector,
@ -263,6 +297,18 @@ class RecordActionTool implements RecorderTool {
});
}
private _commitPendingClickAction() {
if (this._pendingClickAction)
this._performAction(this._pendingClickAction.action);
this._cancelPendingClickAction();
}
private _cancelPendingClickAction() {
if (this._pendingClickAction)
clearTimeout(this._pendingClickAction.timeout);
this._pendingClickAction = undefined;
}
onContextMenu(event: MouseEvent) {
// the 'contextmenu' event is triggered by a right-click or equivalent action,
// and it prevents the click event from firing for that action, so we always
@ -915,6 +961,10 @@ class Overlay {
}
return false;
}
onDblClick(event: MouseEvent) {
return false;
}
}
export class Recorder {
@ -970,6 +1020,7 @@ export class Recorder {
this._listeners = [
addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true),
addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true),
addEventListener(this.document, 'dblclick', event => this._onDblClick(event as MouseEvent), true),
addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true),
addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true),
addEventListener(this.document, 'input', event => this._onInput(event), true),
@ -1043,6 +1094,16 @@ export class Recorder {
this._currentTool.onClick?.(event);
}
private _onDblClick(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this.overlay?.onDblClick(event))
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onDblClick?.(event);
}
private _onContextMenu(event: MouseEvent) {
if (!event.isTrusted)
return;

View file

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

View file

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

View file

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

View file

@ -16,23 +16,28 @@
import { EventEmitter } from 'events';
import type { Frame } from '../frames';
import type { Page } from '../page';
import type { Signal } from './recorderActions';
import type { ActionInContext } from '../codegen/types';
import type { CallMetadata } from '@protocol/callMetadata';
import { createGuid } from '../../utils/crypto';
import { monotonicTime } from '../../utils/time';
import { mainFrameForAction, traceParamsForAction } from './recorderUtils';
export class RecorderCollection extends EventEmitter {
private _currentAction: ActionInContext | null = null;
private _lastAction: ActionInContext | null = null;
private _actions: ActionInContext[] = [];
private _enabled: boolean;
private _pageAliases: Map<Page, string>;
constructor(enabled: boolean) {
constructor(pageAliases: Map<Page, string>, enabled: boolean) {
super();
this._enabled = enabled;
this._pageAliases = pageAliases;
this.restart();
}
restart() {
this._currentAction = null;
this._lastAction = null;
this._actions = [];
this.emit('change');
@ -46,60 +51,63 @@ export class RecorderCollection extends EventEmitter {
this._enabled = enabled;
}
addAction(action: ActionInContext) {
async willPerformAction(actionInContext: ActionInContext): Promise<CallMetadata | null> {
if (!this._enabled)
return;
this.willPerformAction(action);
this.didPerformAction(action);
return null;
const { callMetadata, mainFrame } = this._callMetadataForAction(actionInContext);
await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
this._lastAction = actionInContext;
return callMetadata;
}
willPerformAction(action: ActionInContext) {
if (!this._enabled)
return;
this._currentAction = action;
private _callMetadataForAction(actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
const mainFrame = mainFrameForAction(this._pageAliases, actionInContext);
const { action } = actionInContext;
const callMetadata: CallMetadata = {
id: `call@${createGuid()}`,
apiName: 'frame.' + action.name,
objectId: mainFrame.guid,
pageId: mainFrame._page.guid,
frameId: mainFrame.guid,
startTime: monotonicTime(),
endTime: 0,
type: 'Frame',
method: action.name,
params: traceParamsForAction(actionInContext),
log: [],
};
return { callMetadata, mainFrame };
}
performedActionFailed(action: ActionInContext) {
async didPerformAction(callMetadata: CallMetadata, actionInContext: ActionInContext, error?: Error) {
if (!this._enabled)
return;
if (this._currentAction === action)
this._currentAction = null;
if (!error)
this._actions.push(actionInContext);
const mainFrame = mainFrameForAction(this._pageAliases, actionInContext);
callMetadata.endTime = monotonicTime();
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
this.emit('change');
}
didPerformAction(actionInContext: ActionInContext) {
addRecordedAction(actionInContext: ActionInContext) {
if (!this._enabled)
return;
const action = actionInContext.action;
let eraseLastAction = false;
if (this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias) {
const lastAction = this._lastAction.action;
// We augment last action based on the type.
if (this._lastAction && action.name === 'fill' && lastAction.name === 'fill') {
if (action.selector === lastAction.selector)
eraseLastAction = true;
}
if (lastAction && action.name === 'click' && lastAction.name === 'click') {
if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount)
eraseLastAction = true;
}
if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') {
if (action.url === lastAction.url) {
// Already at a target URL.
this._currentAction = null;
return;
}
}
// Check and uncheck erase click.
if (lastAction && (action.name === 'check' || action.name === 'uncheck') && lastAction.name === 'click') {
if (action.selector === lastAction.selector)
eraseLastAction = true;
}
const lastAction = this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias ? this._lastAction.action : undefined;
if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate' && action.url === lastAction.url) {
// Already at a target URL.
return;
}
this._lastAction = actionInContext;
this._currentAction = null;
if (eraseLastAction)
if (lastAction && action.name === 'fill' && lastAction.name === 'fill' && action.selector === lastAction.selector)
this._actions.pop();
this._lastAction = actionInContext;
this._actions.push(actionInContext);
this.emit('change');
}
@ -116,25 +124,14 @@ export class RecorderCollection extends EventEmitter {
if (!this._enabled)
return;
// Signal either arrives while action is being performed or shortly after.
if (this._currentAction) {
this._currentAction.action.signals.push(signal);
return;
}
if (this._lastAction && (!this._lastAction.committed || signal.name !== 'navigation')) {
const signals = this._lastAction.action.signals;
if (signal.name === 'navigation' && signals.length && signals[signals.length - 1].name === 'download')
return;
if (signal.name === 'download' && signals.length && signals[signals.length - 1].name === 'navigation')
signals.length = signals.length - 1;
if (this._lastAction && !this._lastAction.committed) {
this._lastAction.action.signals.push(signal);
this.emit('change');
return;
}
if (signal.name === 'navigation' && frame._page.mainFrame() === frame) {
this.addAction({
this.addRecordedAction({
frame: {
pageAlias,
framePath: [],

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.
*/
import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils';
import { toClickOptions, toKeyboardModifiers } from '../codegen/language';
import { serializeExpectedTextValues } from '../../utils';
import { toKeyboardModifiers } from '../codegen/language';
import type { ActionInContext } from '../codegen/types';
import type { Frame } from '../frames';
import type { CallMetadata } from '../instrumentation';
import type { Page } from '../page';
import { buildFullSelector } from './recorderUtils';
import type * as actions from './recorderActions';
import type * as types from '../types';
import { buildFullSelector, mainFrameForAction } from './recorderUtils';
async function innerPerformAction(mainFrame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise<any>): Promise<boolean> {
const callMetadata: CallMetadata = {
id: `call@${createGuid()}`,
apiName: 'frame.' + action,
objectId: mainFrame.guid,
pageId: mainFrame._page.guid,
frameId: mainFrame.guid,
startTime: monotonicTime(),
endTime: 0,
type: 'Frame',
method: action,
params,
log: [],
};
try {
await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
await cb(callMetadata);
} catch (e) {
callMetadata.endTime = monotonicTime();
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
return false;
}
callMetadata.endTime = monotonicTime();
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
return true;
}
export async function performAction(pageAliases: Map<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();
export async function performAction(callMetadata: CallMetadata, pageAliases: Map<Page, string>, actionInContext: ActionInContext) {
const mainFrame = mainFrameForAction(pageAliases, actionInContext);
const { action } = actionInContext;
const kActionTimeout = 5000;
if (action.name === 'navigate')
return await innerPerformAction(mainFrame, 'goto', { url: action.url }, callMetadata => mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout }));
if (action.name === 'navigate') {
await mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout });
return;
}
if (action.name === 'openPage')
throw Error('Not reached');
if (action.name === 'closePage')
return await innerPerformAction(mainFrame, 'close', {}, callMetadata => mainFrame._page.close(callMetadata));
if (action.name === 'closePage') {
await mainFrame._page.close(callMetadata);
return;
}
const selector = buildFullSelector(actionInContext.frame.framePath, action.selector);
if (action.name === 'click') {
const options = toClickOptions(action);
return await innerPerformAction(mainFrame, 'click', { selector }, callMetadata => mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true }));
await mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'press') {
const modifiers = toKeyboardModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
return await innerPerformAction(mainFrame, 'press', { selector, key: shortcut }, callMetadata => mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true }));
await mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'fill')
return await innerPerformAction(mainFrame, 'fill', { selector, text: action.text }, callMetadata => mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true }));
if (action.name === 'setInputFiles')
return await innerPerformAction(mainFrame, 'setInputFiles', { selector, files: action.files }, callMetadata => mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true }));
if (action.name === 'check')
return await innerPerformAction(mainFrame, 'check', { selector }, callMetadata => mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true }));
if (action.name === 'uncheck')
return await innerPerformAction(mainFrame, 'uncheck', { selector }, callMetadata => mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true }));
if (action.name === 'fill') {
await mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'setInputFiles') {
await mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'check') {
await mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'uncheck') {
await mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'select') {
const values = action.options.map(value => ({ value }));
return await innerPerformAction(mainFrame, 'selectOption', { selector, values }, callMetadata => mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true }));
await mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'assertChecked') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, {
await mainFrame.expect(callMetadata, selector, {
selector,
expression: 'to.be.checked',
isNot: !action.checked,
timeout: kActionTimeout,
}));
});
return;
}
if (action.name === 'assertText') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, {
await mainFrame.expect(callMetadata, selector, {
selector,
expression: 'to.have.text',
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
isNot: false,
timeout: kActionTimeout,
}));
});
return;
}
if (action.name === 'assertValue') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, {
await mainFrame.expect(callMetadata, selector, {
selector,
expression: 'to.have.value',
expectedValue: action.value,
isNot: false,
timeout: kActionTimeout,
}));
});
return;
}
if (action.name === 'assertVisible') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, {
await mainFrame.expect(callMetadata, selector, {
selector,
expression: 'to.be.visible',
isNot: false,
timeout: kActionTimeout,
}));
});
return;
}
throw new Error('Internal error: unexpected action ' + (action as any).name);
}
export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions {
const modifiers = toKeyboardModifiers(action.modifiers);
const options: types.MouseClickOptions = {};
if (action.button !== 'left')
options.button = action.button;
if (modifiers.length)
options.modifiers = modifiers;
if (action.clickCount > 1)
options.clickCount = action.clickCount;
if (action.position)
options.position = action.position;
return options;
}

View file

@ -20,6 +20,8 @@ import type { Page } from '../page';
import type { ActionInContext } from '../codegen/types';
import type { Frame } from '../frames';
import type * as actions from './recorderActions';
import { toKeyboardModifiers } from '../codegen/language';
import { serializeExpectedTextValues } from '../../utils/expectUtils';
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
let title = metadata.apiName || metadata.method;
@ -72,3 +74,58 @@ export async function frameForAction(pageAliases: Map<Page, string>, actionInCon
throw new Error('Internal error: frame not found');
return result.frame;
}
export function traceParamsForAction(actionInContext: ActionInContext) {
const { action } = actionInContext;
switch (action.name) {
case 'navigate': return { url: action.url };
case 'openPage': return {};
case 'closePage': return {};
}
const selector = buildFullSelector(actionInContext.frame.framePath, action.selector);
switch (action.name) {
case 'click': return { selector, clickCount: action.clickCount };
case 'press': {
const modifiers = toKeyboardModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
return { selector, key: shortcut };
}
case 'fill': return { selector, text: action.text };
case 'setInputFiles': return { selector, files: action.files };
case 'check': return { selector };
case 'uncheck': return { selector };
case 'select': return { selector, values: action.options.map(value => ({ value })) };
case 'assertChecked': {
return {
selector,
expression: 'to.be.checked',
isNot: !action.checked,
};
}
case 'assertText': {
return {
selector,
expression: 'to.have.text',
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
isNot: false,
};
}
case 'assertValue': {
return {
selector,
expression: 'to.have.value',
expectedValue: action.value,
isNot: false,
};
}
case 'assertVisible': {
return {
selector,
expression: 'to.be.visible',
isNot: false,
};
}
}
}

View file

@ -16,6 +16,7 @@
import {
captureRawStack,
createGuid,
isString,
pollAgainstDeadline } from 'playwright-core/lib/utils';
import type { ExpectZone } from 'playwright-core/lib/utils';
@ -104,11 +105,17 @@ export const printReceivedStringContainExpectedResult = (
type ExpectMessage = string | { message?: string };
function createMatchers(actual: unknown, info: ExpectMetaInfo): any {
return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info));
function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]): any {
return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info, prefix));
}
function createExpect(info: ExpectMetaInfo) {
const getCustomMatchersSymbol = Symbol('get custom matchers');
function qualifiedMatcherName(qualifier: string[], matcherName: string) {
return qualifier.join(':') + '$' + matcherName;
}
function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record<string, Function>) {
const expectInstance: Expect<{}> = new Proxy(expectLibrary, {
apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) {
const [actual, messageOrOptions] = argumentsList;
@ -119,18 +126,22 @@ function createExpect(info: ExpectMetaInfo) {
throw new Error('`expect.poll()` accepts only function as a first argument');
newInfo.generator = actual as any;
}
return createMatchers(actual, newInfo);
return createMatchers(actual, newInfo, prefix);
},
get: function(target: any, property: string) {
get: function(target: any, property: string | typeof getCustomMatchersSymbol) {
if (property === 'configure')
return configure;
if (property === 'extend') {
return (matchers: any) => {
const qualifier = [...prefix, createGuid()];
const wrappedMatchers: any = {};
const extendedMatchers: any = { ...customMatchers };
for (const [name, matcher] of Object.entries(matchers)) {
wrappedMatchers[name] = function(...args: any[]) {
const key = qualifiedMatcherName(qualifier, name);
wrappedMatchers[key] = function(...args: any[]) {
const { isNot, promise, utils } = this;
const newThis: ExpectMatcherState = {
isNot,
@ -141,9 +152,12 @@ function createExpect(info: ExpectMetaInfo) {
(newThis as any).equals = throwUnsupportedExpectMatcherError;
return (matcher as any).call(newThis, ...args);
};
Object.defineProperty(wrappedMatchers[key], 'name', { value: name });
extendedMatchers[name] = wrappedMatchers[key];
}
expectLibrary.extend(wrappedMatchers);
return expectInstance;
return createExpect(info, qualifier, extendedMatchers);
};
}
@ -153,6 +167,9 @@ function createExpect(info: ExpectMetaInfo) {
};
}
if (property === getCustomMatchersSymbol)
return customMatchers;
if (property === 'poll') {
return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => {
const poll = isString(messageOrOptions) ? {} : messageOrOptions || {};
@ -178,7 +195,7 @@ function createExpect(info: ExpectMetaInfo) {
newInfo.pollIntervals = configuration._poll.intervals;
}
}
return createExpect(newInfo);
return createExpect(newInfo, prefix, customMatchers);
};
return expectInstance;
@ -241,15 +258,28 @@ type ExpectMetaInfo = {
class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
private _info: ExpectMetaInfo;
private _prefix: string[];
constructor(info: ExpectMetaInfo) {
constructor(info: ExpectMetaInfo, prefix: string[]) {
this._info = { ...info };
this._prefix = prefix;
}
get(target: Object, matcherName: string | symbol, receiver: any): any {
let matcher = Reflect.get(target, matcherName, receiver);
if (typeof matcherName !== 'string')
return matcher;
let resolvedMatcherName = matcherName;
for (let i = this._prefix.length; i > 0; i--) {
const qualifiedName = qualifiedMatcherName(this._prefix.slice(0, i), matcherName);
if (Reflect.has(target, qualifiedName)) {
matcher = Reflect.get(target, qualifiedName, receiver);
resolvedMatcherName = qualifiedName;
break;
}
}
if (matcher === undefined)
throw new Error(`expect: Property '${matcherName}' not found.`);
if (typeof matcher !== 'function') {
@ -260,7 +290,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
if (this._info.isPoll) {
if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects')
throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`);
matcher = (...args: any[]) => pollMatcher(matcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args);
matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args);
}
return (...args: any[]) => {
const testInfo = currentTestInfo();
@ -320,7 +350,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<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 { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout);
@ -333,7 +363,7 @@ async function pollMatcher(matcherName: any, isNot: boolean, pollIntervals: numb
if (isNot)
expectInstance = expectInstance.not;
try {
expectInstance[matcherName].call(expectInstance, ...args);
expectInstance[qualifiedMatcherName].call(expectInstance, ...args);
return { continuePolling: false, result: undefined };
} catch (error) {
return { continuePolling: true, result: error };
@ -375,8 +405,15 @@ function computeArgsSuffix(matcherName: string, args: any[]) {
return value ? `(${value})` : '';
}
export const expect: Expect<{}> = createExpect({}).extend(customMatchers);
export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers);
export function mergeExpects(...expects: any[]) {
return expect;
let merged = expect;
for (const e of expects) {
const internals = e[getCustomMatchersSymbol];
if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special
continue;
merged = merged.extend(internals);
}
return merged;
}

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

View file

@ -72,6 +72,7 @@ export const UIModeView: React.FC<{}> = ({
}) => {
const [filterText, setFilterText] = React.useState<string>('');
const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(false);
const [outputContainsError, setOutputContainsError] = React.useState(false);
const [statusFilters, setStatusFilters] = React.useState<Map<string, boolean>>(new Map([
['passed', false],
['failed', false],
@ -134,6 +135,9 @@ export const UIModeView: React.FC<{}> = ({
} else {
xtermDataSource.write(params.text!);
}
if (params.type === 'stderr')
setOutputContainsError(true);
}),
testServerConnection.onClose(() => setIsDisconnected(true))
];
@ -168,6 +172,7 @@ export const UIModeView: React.FC<{}> = ({
},
onError: error => {
xtermDataSource.write((error.stack || error.value || '') + '\n');
setOutputContainsError(true);
},
pathSeparator: queryParams.pathSeparator,
});
@ -426,7 +431,7 @@ export const UIModeView: React.FC<{}> = ({
<div className={clsx('vbox', !isShowingOutput && 'hidden')}>
<Toolbar>
<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>
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
</Toolbar>
@ -447,7 +452,10 @@ export const UIModeView: React.FC<{}> = ({
<img src='playwright-logo.svg' alt='Playwright logo' />
<div className='section-title'>Playwright</div>
<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} />}
</Toolbar>
<FiltersView

View file

@ -45,6 +45,7 @@ export default defineConfig({
index: path.resolve(__dirname, 'index.html'),
uiMode: path.resolve(__dirname, 'uiMode.html'),
embedded: path.resolve(__dirname, 'embedded.html'),
recorder: path.resolve(__dirname, 'recorder.html'),
snapshot: path.resolve(__dirname, 'snapshot.html'),
},
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 }) => {
// Currently an upstream bug in the network stack of Chromium which leads that
// the wrong proxy gets used in the BrowserContext.
it.fixme(browserName === 'chromium' && platform === 'win32');
proxyServer.forwardTo(server.PORT);
let browser;
try {

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 kValidationSubTests: [BrowserContextOptions, string][] = [
[{ 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');
});
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 }) => {
const recorder = await openRecorder();

View file

@ -191,6 +191,13 @@ class Recorder {
await this.page.mouse.up(options);
}
async trustedDblclick() {
await this.page.mouse.down();
await this.page.mouse.up();
await this.page.mouse.down({ clickCount: 2 });
await this.page.mouse.up();
}
async focusElement(selector: string): Promise<string> {
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.fixme(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/10095');
function hasAuth(req, res) {
const auth = req.headers['proxy-authorization'];
if (!auth) {
@ -324,7 +323,6 @@ async function setupSocksForwardingServer(port: number, forwardPort: number) {
}
it('should use SOCKS proxy for websocket requests', async ({ browserName, platform, browserType, server }, testInfo) => {
it.fixme(browserName === 'webkit' && platform !== 'linux');
const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer(testInfo.workerIndex + 2048 + 2, server.PORT);
const browser = await browserType.launch({
proxy: {

View file

@ -18,41 +18,6 @@ import path from 'path';
import { test, expect, parseTestRunnerOutput, stripAnsi } from './playwright-test-fixtures';
const { spawnAsync } = require('../../packages/playwright-core/lib/utils');
test('should be able to call expect.extend in config', async ({ runInlineTest }) => {
const result = await runInlineTest({
'helper.ts': `
import { test as base, expect } from '@playwright/test';
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
if (pass) {
return {
message: () =>
'passed',
pass: true,
};
} else {
return {
message: () => 'failed',
pass: false,
};
}
},
});
export const test = base;
`,
'expect-test.spec.ts': `
import { test } from './helper';
test('numeric ranges', () => {
test.expect(100).toBeWithinRange(90, 110);
test.expect(101).not.toBeWithinRange(0, 100);
});
`
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should not expand huge arrays', async ({ runInlineTest }) => {
const result = await runInlineTest({
'expect-test.spec.ts': `
@ -1043,8 +1008,8 @@ test('should expose timeout to custom matchers', async ({ runInlineTest, runTSC
test('should throw error when using .equals()', async ({ runInlineTest }) => {
const result = await runInlineTest({
'helper.ts': `
import { test as base, expect } from '@playwright/test';
expect.extend({
import { test as base, expect as baseExpect } from '@playwright/test';
export const expect = baseExpect.extend({
toBeWithinRange(received, floor, ceiling) {
this.equals(1, 2);
},
@ -1052,10 +1017,10 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => {
export const test = base;
`,
'expect-test.spec.ts': `
import { test } from './helper';
import { test, expect } from './helper';
test('numeric ranges', () => {
test.expect(() => {
test.expect(100).toBeWithinRange(90, 110);
expect(() => {
expect(100).toBeWithinRange(90, 110);
}).toThrowError('It looks like you are using custom expect matchers that are not compatible with Playwright. See https://aka.ms/playwright/expect-compatibility');
});
`
@ -1063,3 +1028,44 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('expect.extend should be immutable', async ({ runInlineTest }) => {
const result = await runInlineTest({
'expect-test.spec.ts': `
import { test, expect } from '@playwright/test';
const expectFoo = expect.extend({
toFoo() {
console.log('%%foo');
return { pass: true };
}
});
const expectFoo2 = expect.extend({
toFoo() {
console.log('%%foo2');
return { pass: true };
}
});
const expectBar = expectFoo.extend({
toBar() {
console.log('%%bar');
return { pass: true };
}
});
test('logs', () => {
expect(expectFoo).not.toBe(expectFoo2);
expect(expectFoo).not.toBe(expectBar);
expectFoo().toFoo();
expectFoo2().toFoo();
expectBar().toFoo();
expectBar().toBar();
});
`
});
expect(result.outputLines).toEqual([
'foo',
'foo2',
'foo',
'bar',
]);
});

View file

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