chore: perform double click while recording (#32576)
This commit is contained in:
parent
5e086be36b
commit
d051495c7a
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../../types/types';
|
import type { BrowserContextOptions } from '../../../types/types';
|
||||||
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import { sanitizeDeviceOptions, toClickOptions, toKeyboardModifiers, toSignalMap } from './language';
|
import { sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language';
|
||||||
import { escapeWithQuotes, asLocator } from '../../utils';
|
import { escapeWithQuotes, asLocator } from '../../utils';
|
||||||
import { deviceDescriptors } from '../deviceDescriptors';
|
import { deviceDescriptors } from '../deviceDescriptors';
|
||||||
|
|
||||||
|
|
@ -112,7 +112,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
||||||
let method = 'Click';
|
let method = 'Click';
|
||||||
if (action.clickCount === 2)
|
if (action.clickCount === 2)
|
||||||
method = 'DblClick';
|
method = 'DblClick';
|
||||||
const options = toClickOptions(action);
|
const options = toClickOptionsForSourceCode(action);
|
||||||
if (!Object.entries(options).length)
|
if (!Object.entries(options).length)
|
||||||
return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`;
|
return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`;
|
||||||
const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options');
|
const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options');
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import type { BrowserContextOptions } from '../../../types/types';
|
import type { BrowserContextOptions } from '../../../types/types';
|
||||||
import type * as types from '../types';
|
import type * as types from '../types';
|
||||||
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import { toClickOptions, toKeyboardModifiers, toSignalMap } from './language';
|
import { toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language';
|
||||||
import { deviceDescriptors } from '../deviceDescriptors';
|
import { deviceDescriptors } from '../deviceDescriptors';
|
||||||
import { JavaScriptFormatter } from './javascript';
|
import { JavaScriptFormatter } from './javascript';
|
||||||
import { escapeWithQuotes, asLocator } from '../../utils';
|
import { escapeWithQuotes, asLocator } from '../../utils';
|
||||||
|
|
@ -101,7 +101,7 @@ export class JavaLanguageGenerator implements LanguageGenerator {
|
||||||
let method = 'click';
|
let method = 'click';
|
||||||
if (action.clickCount === 2)
|
if (action.clickCount === 2)
|
||||||
method = 'dblclick';
|
method = 'dblclick';
|
||||||
const options = toClickOptions(action);
|
const options = toClickOptionsForSourceCode(action);
|
||||||
const optionsText = formatClickOptions(options);
|
const optionsText = formatClickOptions(options);
|
||||||
return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`;
|
return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../../types/types';
|
import type { BrowserContextOptions } from '../../../types/types';
|
||||||
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language';
|
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language';
|
||||||
import { deviceDescriptors } from '../deviceDescriptors';
|
import { deviceDescriptors } from '../deviceDescriptors';
|
||||||
import { escapeWithQuotes, asLocator } from '../../utils';
|
import { escapeWithQuotes, asLocator } from '../../utils';
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
||||||
let method = 'click';
|
let method = 'click';
|
||||||
if (action.clickCount === 2)
|
if (action.clickCount === 2)
|
||||||
method = 'dblclick';
|
method = 'dblclick';
|
||||||
const options = toClickOptions(action);
|
const options = toClickOptionsForSourceCode(action);
|
||||||
const optionsString = formatOptions(options, false);
|
const optionsString = formatOptions(options, false);
|
||||||
return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`;
|
return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,14 @@ export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModif
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions {
|
export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions {
|
||||||
const modifiers = toKeyboardModifiers(action.modifiers);
|
const modifiers = toKeyboardModifiers(action.modifiers);
|
||||||
const options: types.MouseClickOptions = {};
|
const options: types.MouseClickOptions = {};
|
||||||
if (action.button !== 'left')
|
if (action.button !== 'left')
|
||||||
options.button = action.button;
|
options.button = action.button;
|
||||||
if (modifiers.length)
|
if (modifiers.length)
|
||||||
options.modifiers = modifiers;
|
options.modifiers = modifiers;
|
||||||
|
// Do not render clickCount === 2 for dblclick.
|
||||||
if (action.clickCount > 2)
|
if (action.clickCount > 2)
|
||||||
options.clickCount = action.clickCount;
|
options.clickCount = action.clickCount;
|
||||||
if (action.position)
|
if (action.position)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../../types/types';
|
import type { BrowserContextOptions } from '../../../types/types';
|
||||||
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language';
|
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language';
|
||||||
import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils';
|
import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils';
|
||||||
import { deviceDescriptors } from '../deviceDescriptors';
|
import { deviceDescriptors } from '../deviceDescriptors';
|
||||||
|
|
||||||
|
|
@ -94,7 +94,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
|
||||||
let method = 'click';
|
let method = 'click';
|
||||||
if (action.clickCount === 2)
|
if (action.clickCount === 2)
|
||||||
method = 'dblclick';
|
method = 'dblclick';
|
||||||
const options = toClickOptions(action);
|
const options = toClickOptionsForSourceCode(action);
|
||||||
const optionsString = formatOptions(options, false);
|
const optionsString = formatOptions(options, false);
|
||||||
return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`;
|
return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ interface RecorderTool {
|
||||||
cursor(): string;
|
cursor(): string;
|
||||||
cleanup?(): void;
|
cleanup?(): void;
|
||||||
onClick?(event: MouseEvent): void;
|
onClick?(event: MouseEvent): void;
|
||||||
|
onDblClick?(event: MouseEvent): void;
|
||||||
onContextMenu?(event: MouseEvent): void;
|
onContextMenu?(event: MouseEvent): void;
|
||||||
onDragStart?(event: DragEvent): void;
|
onDragStart?(event: DragEvent): void;
|
||||||
onInput?(event: Event): void;
|
onInput?(event: Event): void;
|
||||||
|
|
@ -210,6 +211,7 @@ class RecordActionTool implements RecorderTool {
|
||||||
private _hoveredElement: HTMLElement | null = null;
|
private _hoveredElement: HTMLElement | null = null;
|
||||||
private _activeModel: HighlightModel | null = null;
|
private _activeModel: HighlightModel | null = null;
|
||||||
private _expectProgrammaticKeyUp = false;
|
private _expectProgrammaticKeyUp = false;
|
||||||
|
private _pendingClickAction: { action: actions.ClickAction, timeout: NodeJS.Timeout } | undefined;
|
||||||
|
|
||||||
constructor(recorder: Recorder) {
|
constructor(recorder: Recorder) {
|
||||||
this._recorder = recorder;
|
this._recorder = recorder;
|
||||||
|
|
@ -252,6 +254,38 @@ class RecordActionTool implements RecorderTool {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._cancelPendingClickAction();
|
||||||
|
|
||||||
|
// Stall click in case we are observing double-click.
|
||||||
|
if (event.detail === 1) {
|
||||||
|
this._pendingClickAction = {
|
||||||
|
action: {
|
||||||
|
name: 'click',
|
||||||
|
selector: this._hoveredModel!.selector,
|
||||||
|
position: positionForEvent(event),
|
||||||
|
signals: [],
|
||||||
|
button: buttonForEvent(event),
|
||||||
|
modifiers: modifiersForEvent(event),
|
||||||
|
clickCount: event.detail
|
||||||
|
},
|
||||||
|
timeout: setTimeout(() => this._commitPendingClickAction(), 200)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDblClick(event: MouseEvent) {
|
||||||
|
if (isRangeInput(this._hoveredElement))
|
||||||
|
return;
|
||||||
|
if (this._shouldIgnoreMouseEvent(event))
|
||||||
|
return;
|
||||||
|
// Only allow double click dispatch while action is in progress.
|
||||||
|
if (this._actionInProgress(event))
|
||||||
|
return;
|
||||||
|
if (this._consumedDueToNoModel(event, this._hoveredModel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
this._cancelPendingClickAction();
|
||||||
|
|
||||||
this._performAction({
|
this._performAction({
|
||||||
name: 'click',
|
name: 'click',
|
||||||
selector: this._hoveredModel!.selector,
|
selector: this._hoveredModel!.selector,
|
||||||
|
|
@ -263,6 +297,18 @@ class RecordActionTool implements RecorderTool {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _commitPendingClickAction() {
|
||||||
|
if (this._pendingClickAction)
|
||||||
|
this._performAction(this._pendingClickAction.action);
|
||||||
|
this._cancelPendingClickAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cancelPendingClickAction() {
|
||||||
|
if (this._pendingClickAction)
|
||||||
|
clearTimeout(this._pendingClickAction.timeout);
|
||||||
|
this._pendingClickAction = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
onContextMenu(event: MouseEvent) {
|
onContextMenu(event: MouseEvent) {
|
||||||
// the 'contextmenu' event is triggered by a right-click or equivalent action,
|
// the 'contextmenu' event is triggered by a right-click or equivalent action,
|
||||||
// and it prevents the click event from firing for that action, so we always
|
// and it prevents the click event from firing for that action, so we always
|
||||||
|
|
@ -915,6 +961,10 @@ class Overlay {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDblClick(event: MouseEvent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Recorder {
|
export class Recorder {
|
||||||
|
|
@ -970,6 +1020,7 @@ export class Recorder {
|
||||||
this._listeners = [
|
this._listeners = [
|
||||||
addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true),
|
addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true),
|
||||||
addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true),
|
addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true),
|
||||||
|
addEventListener(this.document, 'dblclick', event => this._onDblClick(event as MouseEvent), true),
|
||||||
addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true),
|
addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true),
|
||||||
addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true),
|
addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true),
|
||||||
addEventListener(this.document, 'input', event => this._onInput(event), true),
|
addEventListener(this.document, 'input', event => this._onInput(event), true),
|
||||||
|
|
@ -1043,6 +1094,16 @@ export class Recorder {
|
||||||
this._currentTool.onClick?.(event);
|
this._currentTool.onClick?.(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _onDblClick(event: MouseEvent) {
|
||||||
|
if (!event.isTrusted)
|
||||||
|
return;
|
||||||
|
if (this.overlay?.onDblClick(event))
|
||||||
|
return;
|
||||||
|
if (this._ignoreOverlayEvent(event))
|
||||||
|
return;
|
||||||
|
this._currentTool.onDblClick?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
private _onContextMenu(event: MouseEvent) {
|
private _onContextMenu(event: MouseEvent) {
|
||||||
if (!event.isTrusted)
|
if (!event.isTrusted)
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,6 @@ export class RecorderCollection extends EventEmitter {
|
||||||
if (action.selector === lastAction.selector)
|
if (action.selector === lastAction.selector)
|
||||||
eraseLastAction = true;
|
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 (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') {
|
||||||
if (action.url === lastAction.url) {
|
if (action.url === lastAction.url) {
|
||||||
// Already at a target URL.
|
// Already at a target URL.
|
||||||
|
|
@ -89,11 +85,6 @@ export class RecorderCollection extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Check and uncheck erase click.
|
|
||||||
if (lastAction && (action.name === 'check' || action.name === 'uncheck') && lastAction.name === 'click') {
|
|
||||||
if (action.selector === lastAction.selector)
|
|
||||||
eraseLastAction = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._lastAction = actionInContext;
|
this._lastAction = actionInContext;
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils';
|
import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils';
|
||||||
import { toClickOptions, toKeyboardModifiers } from '../codegen/language';
|
import { toKeyboardModifiers } from '../codegen/language';
|
||||||
import type { ActionInContext } from '../codegen/types';
|
import type { ActionInContext } from '../codegen/types';
|
||||||
import type { Frame } from '../frames';
|
import type { Frame } from '../frames';
|
||||||
import type { CallMetadata } from '../instrumentation';
|
import type { CallMetadata } from '../instrumentation';
|
||||||
import type { Page } from '../page';
|
import type { Page } from '../page';
|
||||||
|
import type * as actions from './recorderActions';
|
||||||
|
import type * as types from '../types';
|
||||||
import { buildFullSelector } from './recorderUtils';
|
import { buildFullSelector } from './recorderUtils';
|
||||||
|
|
||||||
async function innerPerformAction(mainFrame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise<any>): Promise<boolean> {
|
async function innerPerformAction(mainFrame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise<any>): Promise<boolean> {
|
||||||
|
|
@ -126,3 +128,17 @@ export async function performAction(pageAliases: Map<Page, string>, actionInCont
|
||||||
}
|
}
|
||||||
throw new Error('Internal error: unexpected action ' + (action as any).name);
|
throw new Error('Internal error: unexpected action ' + (action as any).name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions {
|
||||||
|
const modifiers = toKeyboardModifiers(action.modifiers);
|
||||||
|
const options: types.MouseClickOptions = {};
|
||||||
|
if (action.button !== 'left')
|
||||||
|
options.button = action.button;
|
||||||
|
if (modifiers.length)
|
||||||
|
options.modifiers = modifiers;
|
||||||
|
if (action.clickCount > 1)
|
||||||
|
options.clickCount = action.clickCount;
|
||||||
|
if (action.position)
|
||||||
|
options.position = action.position;
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,46 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`)
|
||||||
expect(message.text()).toBe('click');
|
expect(message.text()).toBe('click');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should double click', async ({ page, openRecorder }) => {
|
||||||
|
const recorder = await openRecorder();
|
||||||
|
|
||||||
|
await recorder.setContentAndWait(`<button onclick="console.log('click ' + event.detail)" ondblclick="console.log('dblclick ' + event.detail)">Submit</button>`);
|
||||||
|
|
||||||
|
const locator = await recorder.hoverOverElement('button');
|
||||||
|
expect(locator).toBe(`getByRole('button', { name: 'Submit' })`);
|
||||||
|
|
||||||
|
const messages: string[] = [];
|
||||||
|
page.on('console', message => {
|
||||||
|
if (message.text().includes('click'))
|
||||||
|
messages.push(message.text());
|
||||||
|
});
|
||||||
|
const [, sources] = await Promise.all([
|
||||||
|
page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === 'dblclick 2'),
|
||||||
|
recorder.waitForOutput('JavaScript', 'dblclick'),
|
||||||
|
recorder.trustedDblclick(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect.soft(sources.get('JavaScript')!.text).toContain(`
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).dblclick();`);
|
||||||
|
|
||||||
|
expect.soft(sources.get('Python')!.text).toContain(`
|
||||||
|
page.get_by_role("button", name="Submit").dblclick()`);
|
||||||
|
|
||||||
|
expect.soft(sources.get('Python Async')!.text).toContain(`
|
||||||
|
await page.get_by_role("button", name="Submit").dblclick()`);
|
||||||
|
|
||||||
|
expect.soft(sources.get('Java')!.text).toContain(`
|
||||||
|
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).dblclick()`);
|
||||||
|
|
||||||
|
expect.soft(sources.get('C#')!.text).toContain(`
|
||||||
|
await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync();`);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
'click 1',
|
||||||
|
'click 2',
|
||||||
|
'dblclick 2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('should ignore programmatic events', async ({ page, openRecorder }) => {
|
test('should ignore programmatic events', async ({ page, openRecorder }) => {
|
||||||
const recorder = await openRecorder();
|
const recorder = await openRecorder();
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,13 @@ class Recorder {
|
||||||
await this.page.mouse.up(options);
|
await this.page.mouse.up(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async trustedDblclick() {
|
||||||
|
await this.page.mouse.down();
|
||||||
|
await this.page.mouse.up();
|
||||||
|
await this.page.mouse.down({ clickCount: 2 });
|
||||||
|
await this.page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
async focusElement(selector: string): Promise<string> {
|
async focusElement(selector: string): Promise<string> {
|
||||||
return this.waitForHighlight(() => this.page.focus(selector));
|
return this.waitForHighlight(() => this.page.focus(selector));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue