Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 7 additions & 17 deletions packages/injected/src/recorder/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface RecorderDelegate {
}

interface RecorderTool {
cursor(): string;
cursor?(): string;
install?(): void;
uninstall?(): void;
onClick?(event: MouseEvent): void;
Expand All @@ -63,9 +63,6 @@ interface RecorderTool {
}

class NoneTool implements RecorderTool {
cursor() {
return 'default';
}
}

class InspectTool implements RecorderTool {
Expand Down Expand Up @@ -633,10 +630,6 @@ class JsonRecordActionTool implements RecorderTool {
this._recorder = recorder;
}

cursor() {
return 'pointer';
}

onClick(event: MouseEvent) {
// in webkit, sliding a range element may trigger a click event with a different target if the mouse is released outside the element bounding box.
// So we check the hovered element instead, and if it is a range input, we skip click handling
Expand Down Expand Up @@ -676,13 +669,8 @@ class JsonRecordActionTool implements RecorderTool {
});
}

onDblClick(event: MouseEvent) {
onContextMenu(event: MouseEvent): void {
const element = this._recorder.deepEventTarget(event);
if (isRangeInput(element))
return;
if (this._shouldIgnoreMouseEvent(event))
return;

const { ariaSnapshot, selector, ref } = this._ariaSnapshot(element);
this._recorder.recordAction({
name: 'click',
Expand All @@ -691,9 +679,9 @@ class JsonRecordActionTool implements RecorderTool {
ariaSnapshot,
position: positionForEvent(event),
signals: [],
button: buttonForEvent(event),
button: 'right',
modifiers: modifiersForEvent(event),
clickCount: event.detail,
clickCount: 1,
});
}

Expand Down Expand Up @@ -1351,7 +1339,9 @@ export class Recorder {
this.clearHighlight();
this._currentTool = newTool;
this._currentTool.install?.();
this.injectedScript.document.body?.setAttribute('data-pw-cursor', newTool.cursor());
const cursor = newTool.cursor?.();
if (cursor)
this.injectedScript.document.body?.setAttribute('data-pw-cursor', cursor);
}

setUIState(state: UIState, delegate: RecorderDelegate) {
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ import type * as channels from '@protocol/channels';
import type * as actions from '@recorder/actions';

interface RecorderEventSink {
actionAdded?(page: Page, actionInContext: actions.ActionInContext, code: string[]): void;
actionUpdated?(page: Page, actionInContext: actions.ActionInContext, code: string[]): void;
actionAdded?(page: Page, actionInContext: actions.ActionInContext, code: string): void;
actionUpdated?(page: Page, actionInContext: actions.ActionInContext, code: string): void;
signalAdded?(page: Page, signal: actions.SignalInContext): void;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ scheme.BrowserContextRecorderEventEvent = tObject({
event: tEnum(['actionAdded', 'actionUpdated', 'signalAdded']),
data: tAny,
page: tChannel(['Page']),
code: tArray(tString),
code: tString,
});
scheme.BrowserContextAddCookiesParams = tObject({
cookies: tArray(tType('SetNetworkCookie')),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
page: PageDispatcher.fromNullable(this, request.frame()?._page.initializedOrUndefined()),
});
});
this.addObjectListener(BrowserContext.Events.RecorderEvent, ({ event, data, page, code }: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page, code: string[] }) => {
this.addObjectListener(BrowserContext.Events.RecorderEvent, ({ event, data, page, code }: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page, code: string }) => {
this._dispatchEvent('recorderEvent', { event, data, code, page: PageDispatcher.from(this, page) });
});
}
Expand Down
6 changes: 3 additions & 3 deletions packages/playwright-core/src/server/recorder/recorderApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,14 +384,14 @@ export class ProgrammaticRecorderApp {
return;
const { actionTexts } = generateCode([action], languageGenerator, languageGeneratorOptions);
if (!lastAction || !shouldMergeAction(action, lastAction))
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionAdded', data: action, page, code: actionTexts });
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionAdded', data: action, page, code: actionTexts.join('\n') });
else
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionUpdated', data: action, page, code: actionTexts });
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionUpdated', data: action, page, code: actionTexts.join('\n') });
lastAction = action;
});
recorder.on(RecorderEvent.SignalAdded, signal => {
const page = findPageByGuid(inspectedContext, signal.frame.pageGuid);
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'signalAdded', data: signal, page, code: [] });
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'signalAdded', data: signal, page, code: '' });
});
}
}
Expand Down
14 changes: 13 additions & 1 deletion packages/playwright-core/src/server/recorder/recorderUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,22 @@ function isSameSelector(action: actions.ActionInContext, lastAction: actions.Act
return 'selector' in action.action && 'selector' in lastAction.action && action.action.selector === lastAction.action.selector;
}

function isShortlyAfter(action: actions.ActionInContext, lastAction: actions.ActionInContext): boolean {
return action.startTime - lastAction.startTime < 500;
}

export function shouldMergeAction(action: actions.ActionInContext, lastAction: actions.ActionInContext | undefined): boolean {
if (!lastAction)
return false;
return isSameAction(action, lastAction) && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector(action, lastAction)));
switch (action.action.name) {
case 'fill':
return isSameAction(action, lastAction) && isSameSelector(action, lastAction);
case 'navigate':
return isSameAction(action, lastAction);
case 'click':
return isSameAction(action, lastAction) && isSameSelector(action, lastAction) && isShortlyAfter(action, lastAction) && action.action.clickCount > (lastAction.action as actions.ClickAction).clickCount;
}
return false;
}

export function collapseActions(actions: actions.ActionInContext[]): actions.ActionInContext[] {
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/src/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1721,7 +1721,7 @@ export type BrowserContextRecorderEventEvent = {
event: 'actionAdded' | 'actionUpdated' | 'signalAdded',
data: any,
page: PageChannel,
code: string[],
code: string,
};
export type BrowserContextAddCookiesParams = {
cookies: SetNetworkCookie[],
Expand Down
4 changes: 1 addition & 3 deletions packages/protocol/src/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1549,9 +1549,7 @@ BrowserContext:
- signalAdded
data: json
page: Page
code:
type: array
items: string
code: string

Page:
type: interface
Expand Down
109 changes: 82 additions & 27 deletions tests/library/inspector/recorder-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ import type { Page } from '@playwright/test';
import type * as actions from '@recorder/actions';

class RecorderLog {
actions: (actions.ActionInContext & { code: string[] })[] = [];
actions: (actions.ActionInContext & { code: string })[] = [];

actionAdded(page: Page, actionInContext: actions.ActionInContext, code: string[]): void {
actionAdded(page: Page, actionInContext: actions.ActionInContext, code: string): void {
this.actions.push({ ...actionInContext, code });
}

actionUpdated(page: Page, actionInContext: actions.ActionInContext, code: string[]): void {
actionUpdated(page: Page, actionInContext: actions.ActionInContext, code: string): void {
this.actions[this.actions.length - 1] = { ...actionInContext, code };
}
}
Expand All @@ -38,28 +38,80 @@ async function startRecording(context) {
recorderMode: 'api',
}, log);
return {
lastAction: () => log.actions[log.actions.length - 1],
action: (name: string) => log.actions.filter(a => a.action.name === name),
};
}

function normalizeCode(code: string): string {
return code.replace(/\s+/g, ' ').trim();
}

test('should click', async ({ context }) => {
const log = await startRecording(context);
const page = await context.newPage();
await page.setContent(`<button onclick="console.log('click')">Submit</button>`);

await page.getByRole('button', { name: 'Submit' }).click();

expect(log.lastAction()).toEqual(
expect.objectContaining({
action: expect.objectContaining({
name: 'click',
selector: 'internal:role=button[name="Submit"i]',
ref: 'e2',
ariaSnapshot: '- button "Submit" [active] [ref=e2] [cursor=pointer]',
}),
code: [` await page.getByRole('button', { name: 'Submit' }).click();`],
startTime: expect.any(Number),
}));
const clickActions = log.action('click');
expect(clickActions).toEqual([
expect.objectContaining({
action: expect.objectContaining({
name: 'click',
selector: 'internal:role=button[name="Submit"i]',
ref: 'e2',
ariaSnapshot: '- button "Submit" [active] [ref=e2]',
}),
startTime: expect.any(Number),
})
]);

expect(normalizeCode(clickActions[0].code)).toEqual(`await page.getByRole('button', { name: 'Submit' }).click();`);
});

test('should double click', async ({ context }) => {
const log = await startRecording(context);
const page = await context.newPage();
await page.setContent(`<button onclick="console.log('click')" ondblclick="console.log('dblclick')">Submit</button>`);
await page.getByRole('button', { name: 'Submit' }).dblclick();

const clickActions = log.action('click');
expect(clickActions).toEqual([
expect.objectContaining({
action: expect.objectContaining({
name: 'click',
clickCount: 2,
selector: 'internal:role=button[name="Submit"i]',
ref: 'e2',
ariaSnapshot: '- button "Submit" [active] [ref=e2]',
}),
startTime: expect.any(Number),
})
]);

expect(normalizeCode(clickActions[0].code)).toEqual(`await page.getByRole('button', { name: 'Submit' }).dblclick();`);
});

test('should right click', async ({ context }) => {
const log = await startRecording(context);
const page = await context.newPage();
await page.setContent(`<button oncontextmenu="console.log('contextmenu')">Submit</button>`);
await page.getByRole('button', { name: 'Submit' }).click({ button: 'right' });

const clickActions = log.action('click');
expect(clickActions).toEqual([
expect.objectContaining({
action: expect.objectContaining({
name: 'click',
button: 'right',
selector: 'internal:role=button[name="Submit"i]',
ref: 'e2',
ariaSnapshot: '- button "Submit" [active] [ref=e2]',
}),
startTime: expect.any(Number),
})
]);

expect(normalizeCode(clickActions[0].code)).toEqual(`await page.getByRole('button', { name: 'Submit' }).click({ button: 'right' });`);
});

test('should type', async ({ context }) => {
Expand All @@ -69,15 +121,18 @@ test('should type', async ({ context }) => {

await page.getByRole('textbox').pressSequentially('Hello');

expect(log.lastAction()).toEqual(
expect.objectContaining({
action: expect.objectContaining({
name: 'fill',
selector: 'internal:role=textbox',
ref: 'e2',
ariaSnapshot: '- textbox [active] [ref=e2] [cursor=pointer]: Hello',
}),
code: [` await page.getByRole('textbox').fill('Hello');`],
startTime: expect.any(Number),
}));
const fillActions = log.action('fill');
expect(fillActions).toEqual([
expect.objectContaining({
action: expect.objectContaining({
name: 'fill',
selector: 'internal:role=textbox',
ref: 'e2',
ariaSnapshot: '- textbox [active] [ref=e2]: Hello',
}),
startTime: expect.any(Number),
})
]);

expect(normalizeCode(fillActions[0].code)).toEqual(`await page.getByRole('textbox').fill('Hello');`);
});
Loading