Merge branch 'main' into sharding-algorithm
This commit is contained in:
commit
171c5e1eee
|
|
@ -1,6 +1,6 @@
|
|||
# 🎭 Playwright
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](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: |
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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});`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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});`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -10,3 +10,6 @@
|
|||
../../utils/**
|
||||
../../utilsBundle.ts
|
||||
../../zipBundle.ts
|
||||
|
||||
[recorderInTraceViewer.ts]
|
||||
../trace/viewer/traceViewer.ts
|
||||
|
|
|
|||
|
|
@ -69,13 +69,13 @@ export class ContextRecorder extends EventEmitter {
|
|||
// Make a copy of options to modify them later.
|
||||
const languageGeneratorOptions: LanguageGeneratorOptions = {
|
||||
browserName: context._browser.options.name,
|
||||
launchOptions: { headless: false, ...params.launchOptions },
|
||||
launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined },
|
||||
contextOptions: { ...params.contextOptions },
|
||||
deviceName: params.device,
|
||||
saveStorage: params.saveStorage,
|
||||
};
|
||||
|
||||
const collection = new RecorderCollection(params.mode === 'recording');
|
||||
const collection = new RecorderCollection(this._pageAliases, params.mode === 'recording');
|
||||
collection.on('change', () => {
|
||||
this._recorderSources = [];
|
||||
for (const languageGenerator of this._orderedLanguages) {
|
||||
|
|
@ -163,7 +163,7 @@ export class ContextRecorder extends EventEmitter {
|
|||
// First page is called page, others are called popup1, popup2, etc.
|
||||
const frame = page.mainFrame();
|
||||
page.on('close', () => {
|
||||
this._collection.addAction({
|
||||
this._collection.addRecordedAction({
|
||||
frame: this._describeMainFrame(page),
|
||||
committed: true,
|
||||
action: {
|
||||
|
|
@ -185,7 +185,7 @@ export class ContextRecorder extends EventEmitter {
|
|||
if (page.opener()) {
|
||||
this._onPopup(page.opener()!, page);
|
||||
} else {
|
||||
this._collection.addAction({
|
||||
this._collection.addRecordedAction({
|
||||
frame: this._describeMainFrame(page),
|
||||
committed: true,
|
||||
action: {
|
||||
|
|
@ -236,14 +236,15 @@ export class ContextRecorder extends EventEmitter {
|
|||
|
||||
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
|
||||
|
||||
this._collection.willPerformAction(actionInContext);
|
||||
const success = await performAction(this._pageAliases, actionInContext);
|
||||
if (success) {
|
||||
this._collection.didPerformAction(actionInContext);
|
||||
const callMetadata = await this._collection.willPerformAction(actionInContext);
|
||||
if (!callMetadata)
|
||||
return;
|
||||
const error = await performAction(callMetadata, this._pageAliases, actionInContext).then(() => undefined).catch((e: Error) => e);
|
||||
await this._collection.didPerformAction(callMetadata, actionInContext, error);
|
||||
if (error)
|
||||
actionInContext.committed = true;
|
||||
else
|
||||
this._setCommittedAfterTimeout(actionInContext);
|
||||
} else {
|
||||
this._collection.performedActionFailed(actionInContext);
|
||||
}
|
||||
}
|
||||
|
||||
private async _recordAction(frame: Frame, action: actions.Action) {
|
||||
|
|
@ -260,7 +261,7 @@ export class ContextRecorder extends EventEmitter {
|
|||
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
|
||||
|
||||
this._setCommittedAfterTimeout(actionInContext);
|
||||
this._collection.addAction(actionInContext);
|
||||
this._collection.addRecordedAction(actionInContext);
|
||||
}
|
||||
|
||||
private _setCommittedAfterTimeout(actionInContext: ActionInContext) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
||||
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.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastAction && action.name === 'fill' && lastAction.name === 'fill' && action.selector === lastAction.selector)
|
||||
this._actions.pop();
|
||||
|
||||
this._lastAction = actionInContext;
|
||||
this._currentAction = null;
|
||||
if (eraseLastAction)
|
||||
this._actions.pop();
|
||||
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: [],
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
28
packages/trace-viewer/recorder.html
Normal file
28
packages/trace-viewer/recorder.html
Normal 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>
|
||||
41
packages/trace-viewer/src/recorder.tsx
Normal file
41
packages/trace-viewer/src/recorder.tsx
Normal 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 />);
|
||||
})();
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
.network-request-details-tab {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
user-select: text;
|
||||
line-height: 24px;
|
||||
margin-left: 10px;
|
||||
|
|
|
|||
15
packages/trace-viewer/src/ui/recorderView.css
Normal file
15
packages/trace-viewer/src/ui/recorderView.css
Normal 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.
|
||||
*/
|
||||
168
packages/trace-viewer/src/ui/recorderView.tsx
Normal file
168
packages/trace-viewer/src/ui/recorderView.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue