chore: perform double click while recording (#32576)

This commit is contained in:
Pavel Feldman 2024-09-12 11:40:44 -07:00 committed by GitHub
parent 5e086be36b
commit d051495c7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 135 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -78,10 +78,6 @@ export class RecorderCollection extends EventEmitter {
if (action.selector === lastAction.selector)
eraseLastAction = true;
}
if (lastAction && action.name === 'click' && lastAction.name === 'click') {
if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount)
eraseLastAction = true;
}
if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') {
if (action.url === lastAction.url) {
// Already at a target URL.
@ -89,11 +85,6 @@ export class RecorderCollection extends EventEmitter {
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;

View file

@ -15,11 +15,13 @@
*/
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 { Frame } from '../frames';
import type { CallMetadata } from '../instrumentation';
import type { Page } from '../page';
import type * as actions from './recorderActions';
import type * as types from '../types';
import { buildFullSelector } from './recorderUtils';
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);
}
export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions {
const modifiers = toKeyboardModifiers(action.modifiers);
const options: types.MouseClickOptions = {};
if (action.button !== 'left')
options.button = action.button;
if (modifiers.length)
options.modifiers = modifiers;
if (action.clickCount > 1)
options.clickCount = action.clickCount;
if (action.position)
options.position = action.position;
return options;
}

View file

@ -52,6 +52,46 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`)
expect(message.text()).toBe('click');
});
test('should double click', async ({ page, openRecorder }) => {
const recorder = await openRecorder();
await recorder.setContentAndWait(`<button onclick="console.log('click ' + event.detail)" ondblclick="console.log('dblclick ' + event.detail)">Submit</button>`);
const locator = await recorder.hoverOverElement('button');
expect(locator).toBe(`getByRole('button', { name: 'Submit' })`);
const messages: string[] = [];
page.on('console', message => {
if (message.text().includes('click'))
messages.push(message.text());
});
const [, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === 'dblclick 2'),
recorder.waitForOutput('JavaScript', 'dblclick'),
recorder.trustedDblclick(),
]);
expect.soft(sources.get('JavaScript')!.text).toContain(`
await page.getByRole('button', { name: 'Submit' }).dblclick();`);
expect.soft(sources.get('Python')!.text).toContain(`
page.get_by_role("button", name="Submit").dblclick()`);
expect.soft(sources.get('Python Async')!.text).toContain(`
await page.get_by_role("button", name="Submit").dblclick()`);
expect.soft(sources.get('Java')!.text).toContain(`
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).dblclick()`);
expect.soft(sources.get('C#')!.text).toContain(`
await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync();`);
expect(messages).toEqual([
'click 1',
'click 2',
'dblclick 2',
]);
});
test('should ignore programmatic events', async ({ page, openRecorder }) => {
const recorder = await openRecorder();

View file

@ -191,6 +191,13 @@ class Recorder {
await this.page.mouse.up(options);
}
async trustedDblclick() {
await this.page.mouse.down();
await this.page.mouse.up();
await this.page.mouse.down({ clickCount: 2 });
await this.page.mouse.up();
}
async focusElement(selector: string): Promise<string> {
return this.waitForHighlight(() => this.page.focus(selector));
}