Skip to content

Commit 424ec65

Browse files
authored
chore: include action ref in generated actions (microsoft#36818)
1 parent 612a25b commit 424ec65

File tree

9 files changed

+135
-31
lines changed

9 files changed

+135
-31
lines changed

packages/injected/src/recorder/recorder.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -650,12 +650,13 @@ class JsonRecordActionTool implements RecorderTool {
650650
return;
651651

652652
const checkbox = asCheckbox(element);
653-
const { ariaSnapshot, selector } = this._ariaSnapshot(element);
653+
const { ariaSnapshot, selector, ref } = this._ariaSnapshot(element);
654654
if (checkbox && event.detail === 1) {
655655
// Interestingly, inputElement.checked is reversed inside this event handler.
656656
this._recorder.recordAction({
657657
name: checkbox.checked ? 'check' : 'uncheck',
658658
selector,
659+
ref,
659660
signals: [],
660661
ariaSnapshot,
661662
});
@@ -665,6 +666,7 @@ class JsonRecordActionTool implements RecorderTool {
665666
this._recorder.recordAction({
666667
name: 'click',
667668
selector,
669+
ref,
668670
ariaSnapshot,
669671
position: positionForEvent(event),
670672
signals: [],
@@ -681,27 +683,29 @@ class JsonRecordActionTool implements RecorderTool {
681683
if (this._shouldIgnoreMouseEvent(event))
682684
return;
683685

684-
const { ariaSnapshot, selector } = this._ariaSnapshot(element);
686+
const { ariaSnapshot, selector, ref } = this._ariaSnapshot(element);
685687
this._recorder.recordAction({
686688
name: 'click',
687689
selector,
690+
ref,
688691
ariaSnapshot,
689692
position: positionForEvent(event),
690693
signals: [],
691694
button: buttonForEvent(event),
692695
modifiers: modifiersForEvent(event),
693-
clickCount: event.detail
696+
clickCount: event.detail,
694697
});
695698
}
696699

697700
onInput(event: Event) {
698701
const element = this._recorder.deepEventTarget(event);
699702

700-
const { ariaSnapshot, selector } = this._ariaSnapshot(element);
703+
const { ariaSnapshot, selector, ref } = this._ariaSnapshot(element);
701704
if (isRangeInput(element)) {
702705
this._recorder.recordAction({
703706
name: 'fill',
704707
selector,
708+
ref,
705709
ariaSnapshot,
706710
signals: [],
707711
text: element.value,
@@ -717,6 +721,7 @@ class JsonRecordActionTool implements RecorderTool {
717721

718722
this._recorder.recordAction({
719723
name: 'fill',
724+
ref,
720725
selector,
721726
ariaSnapshot,
722727
signals: [],
@@ -730,6 +735,7 @@ class JsonRecordActionTool implements RecorderTool {
730735
this._recorder.recordAction({
731736
name: 'select',
732737
selector,
738+
ref,
733739
ariaSnapshot,
734740
options: [...selectElement.selectedOptions].map(option => option.value),
735741
signals: []
@@ -743,7 +749,7 @@ class JsonRecordActionTool implements RecorderTool {
743749
return;
744750

745751
const element = this._recorder.deepEventTarget(event);
746-
const { ariaSnapshot, selector } = this._ariaSnapshot(element);
752+
const { ariaSnapshot, selector, ref } = this._ariaSnapshot(element);
747753

748754
// Similarly to click, trigger checkbox on key event, not input.
749755
if (event.key === ' ') {
@@ -752,6 +758,7 @@ class JsonRecordActionTool implements RecorderTool {
752758
this._recorder.recordAction({
753759
name: checkbox.checked ? 'uncheck' : 'check',
754760
selector,
761+
ref,
755762
ariaSnapshot,
756763
signals: [],
757764
});
@@ -762,6 +769,7 @@ class JsonRecordActionTool implements RecorderTool {
762769
this._recorder.recordAction({
763770
name: 'press',
764771
selector,
772+
ref,
765773
ariaSnapshot,
766774
signals: [],
767775
key: event.key,
@@ -819,12 +827,12 @@ class JsonRecordActionTool implements RecorderTool {
819827
return false;
820828
}
821829

822-
private _ariaSnapshot(element: HTMLElement): { ariaSnapshot: string, selector: string };
823-
private _ariaSnapshot(element: HTMLElement | undefined): { ariaSnapshot: string, selector?: string } {
830+
private _ariaSnapshot(element: HTMLElement): { ariaSnapshot: string, selector: string, ref?: string };
831+
private _ariaSnapshot(element: HTMLElement | undefined): { ariaSnapshot: string, selector?: string, ref?: string } {
824832
const { ariaSnapshot, refs } = this._recorder.injectedScript.ariaSnapshotForRecorder();
825833
const ref = element ? refs.get(element) : undefined;
826-
const selector = ref ? `aria-ref=${ref}` : undefined;
827-
return { ariaSnapshot, selector };
834+
const elementInfo = element ? this._recorder.injectedScript.generateSelector(element, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : undefined;
835+
return { ariaSnapshot, selector: elementInfo?.selector, ref };
828836
}
829837
}
830838

packages/playwright-core/src/client/browserContext.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ import type * as channels from '@protocol/channels';
5050
import type * as actions from '@recorder/actions';
5151

5252
interface RecorderEventSink {
53-
actionAdded(page: Page, actionInContext: actions.ActionInContext): void;
54-
actionUpdated(page: Page, actionInContext: actions.ActionInContext): void;
55-
signalAdded(page: Page, signal: actions.SignalInContext): void;
53+
actionAdded?(page: Page, actionInContext: actions.ActionInContext, code: string[]): void;
54+
actionUpdated?(page: Page, actionInContext: actions.ActionInContext, code: string[]): void;
55+
signalAdded?(page: Page, signal: actions.SignalInContext): void;
5656
}
5757

5858
export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel> implements api.BrowserContext {
@@ -148,13 +148,13 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
148148
this._channel.on('requestFailed', ({ request, failureText, responseEndTiming, page }) => this._onRequestFailed(network.Request.from(request), responseEndTiming, failureText, Page.fromNullable(page)));
149149
this._channel.on('requestFinished', params => this._onRequestFinished(params));
150150
this._channel.on('response', ({ response, page }) => this._onResponse(network.Response.from(response), Page.fromNullable(page)));
151-
this._channel.on('recorderEvent', ({ event, data, page }) => {
151+
this._channel.on('recorderEvent', ({ event, data, page, code }) => {
152152
if (event === 'actionAdded')
153-
this._onRecorderEventSink?.actionAdded(Page.from(page), data as actions.ActionInContext);
153+
this._onRecorderEventSink?.actionAdded?.(Page.from(page), data as actions.ActionInContext, code);
154154
else if (event === 'actionUpdated')
155-
this._onRecorderEventSink?.actionUpdated(Page.from(page), data as actions.ActionInContext);
155+
this._onRecorderEventSink?.actionUpdated?.(Page.from(page), data as actions.ActionInContext, code);
156156
else if (event === 'signalAdded')
157-
this._onRecorderEventSink?.signalAdded(Page.from(page), data as actions.SignalInContext);
157+
this._onRecorderEventSink?.signalAdded?.(Page.from(page), data as actions.SignalInContext);
158158
});
159159
this._closedPromise = new Promise(f => this.once(Events.BrowserContext.Close, f));
160160

packages/playwright-core/src/protocol/validator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,7 @@ scheme.BrowserContextRecorderEventEvent = tObject({
984984
event: tEnum(['actionAdded', 'actionUpdated', 'signalAdded']),
985985
data: tAny,
986986
page: tChannel(['Page']),
987+
code: tArray(tString),
987988
});
988989
scheme.BrowserContextAddCookiesParams = tObject({
989990
cookies: tArray(tType('SetNetworkCookie')),

packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { WritableStreamDispatcher } from './writableStreamDispatcher';
3535
import { createGuid } from '../utils/crypto';
3636
import { urlMatches } from '../../utils/isomorphic/urlMatch';
3737
import { Recorder } from '../recorder';
38-
import { ProgrammaticRecorderApp, RecorderApp } from '../recorder/recorderApp';
38+
import { RecorderApp } from '../recorder/recorderApp';
3939

4040
import type { Artifact } from '../artifact';
4141
import type { ConsoleMessage } from '../console';
@@ -200,8 +200,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
200200
page: PageDispatcher.fromNullable(this, request.frame()?._page.initializedOrUndefined()),
201201
});
202202
});
203-
this.addObjectListener(BrowserContext.Events.RecorderEvent, ({ event, data, page }: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page }) => {
204-
this._dispatchEvent('recorderEvent', { event, data, page: PageDispatcher.from(this, page) });
203+
this.addObjectListener(BrowserContext.Events.RecorderEvent, ({ event, data, page, code }: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page, code: string[] }) => {
204+
this._dispatchEvent('recorderEvent', { event, data, code, page: PageDispatcher.from(this, page) });
205205
});
206206
}
207207

@@ -336,11 +336,6 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
336336
}
337337

338338
async enableRecorder(params: channels.BrowserContextEnableRecorderParams, progress: Progress): Promise<void> {
339-
const recorder = await Recorder.forContext(this._context, params);
340-
if (params.recorderMode === 'api') {
341-
await ProgrammaticRecorderApp.run(this._context, recorder);
342-
return;
343-
}
344339
await RecorderApp.show(this._context, params);
345340
}
346341

packages/playwright-core/src/server/recorder/recorderApp.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ export class RecorderApp {
166166
return;
167167
const recorder = await Recorder.forContext(context, params);
168168
if (params.recorderMode === 'api') {
169-
await ProgrammaticRecorderApp.run(context, recorder);
169+
const browserName = context._browser.options.name;
170+
await ProgrammaticRecorderApp.run(context, recorder, browserName, params);
170171
return;
171172
}
172173
await RecorderApp._show(recorder, context, params);
@@ -364,21 +365,33 @@ export class RecorderApp {
364365
}
365366

366367
export class ProgrammaticRecorderApp {
367-
static async run(inspectedContext: BrowserContext, recorder: Recorder) {
368+
static async run(inspectedContext: BrowserContext, recorder: Recorder, browserName: string, params: channels.BrowserContextEnableRecorderParams) {
368369
let lastAction: actions.ActionInContext | null = null;
370+
const languages = [...languageSet()];
371+
372+
const languageGeneratorOptions = {
373+
browserName: browserName,
374+
launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined },
375+
contextOptions: { ...params.contextOptions },
376+
deviceName: params.device,
377+
saveStorage: params.saveStorage,
378+
};
379+
const languageGenerator = languages.find(l => l.id === params.language) ?? languages.find(l => l.id === 'playwright-test')!;
380+
369381
recorder.on(RecorderEvent.ActionAdded, action => {
370382
const page = findPageByGuid(inspectedContext, action.frame.pageGuid);
371383
if (!page)
372384
return;
385+
const { actionTexts } = generateCode([action], languageGenerator, languageGeneratorOptions);
373386
if (!lastAction || !shouldMergeAction(action, lastAction))
374-
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionAdded', data: action, page });
387+
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionAdded', data: action, page, code: actionTexts });
375388
else
376-
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionUpdated', data: action, page });
389+
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionUpdated', data: action, page, code: actionTexts });
377390
lastAction = action;
378391
});
379392
recorder.on(RecorderEvent.SignalAdded, signal => {
380393
const page = findPageByGuid(inspectedContext, signal.frame.pageGuid);
381-
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'signalAdded', data: signal, page });
394+
inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'signalAdded', data: signal, page, code: [] });
382395
});
383396
}
384397
}

packages/protocol/src/channels.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,6 +1721,7 @@ export type BrowserContextRecorderEventEvent = {
17211721
event: 'actionAdded' | 'actionUpdated' | 'signalAdded',
17221722
data: any,
17231723
page: PageChannel,
1724+
code: string[],
17241725
};
17251726
export type BrowserContextAddCookiesParams = {
17261727
cookies: SetNetworkCookie[],

packages/protocol/src/protocol.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,9 @@ BrowserContext:
15491549
- signalAdded
15501550
data: json
15511551
page: Page
1552+
code:
1553+
type: array
1554+
items: string
15521555

15531556
Page:
15541557
type: interface

packages/recorder/src/actions.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type ActionBase = {
4141

4242
export type ActionWithSelector = ActionBase & {
4343
selector: string,
44+
ref?: string,
4445
};
4546

4647
export type ClickAction = ActionWithSelector & {
@@ -78,9 +79,8 @@ export type ClosesPageAction = ActionBase & {
7879
name: 'closePage',
7980
};
8081

81-
export type PressAction = ActionBase & {
82+
export type PressAction = ActionWithSelector & {
8283
name: 'press',
83-
selector: string,
8484
key: string,
8585
modifiers: number,
8686
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { test, expect } from './inspectorTest';
18+
19+
import type { Page } from '@playwright/test';
20+
import type * as actions from '@recorder/actions';
21+
22+
class RecorderLog {
23+
actions: (actions.ActionInContext & { code: string[] })[] = [];
24+
25+
actionAdded(page: Page, actionInContext: actions.ActionInContext, code: string[]): void {
26+
this.actions.push({ ...actionInContext, code });
27+
}
28+
29+
actionUpdated(page: Page, actionInContext: actions.ActionInContext, code: string[]): void {
30+
this.actions[this.actions.length - 1] = { ...actionInContext, code };
31+
}
32+
}
33+
34+
async function startRecording(context) {
35+
const log = new RecorderLog();
36+
await (context as any)._enableRecorder({
37+
mode: 'recording',
38+
recorderMode: 'api',
39+
}, log);
40+
return {
41+
lastAction: () => log.actions[log.actions.length - 1],
42+
};
43+
}
44+
45+
test('should click', async ({ context }) => {
46+
const log = await startRecording(context);
47+
const page = await context.newPage();
48+
await page.setContent(`<button onclick="console.log('click')">Submit</button>`);
49+
50+
await page.getByRole('button', { name: 'Submit' }).click();
51+
52+
expect(log.lastAction()).toEqual(
53+
expect.objectContaining({
54+
action: expect.objectContaining({
55+
name: 'click',
56+
selector: 'internal:role=button[name="Submit"i]',
57+
ref: 'e2',
58+
ariaSnapshot: '- button "Submit" [active] [ref=e2] [cursor=pointer]',
59+
}),
60+
code: [` await page.getByRole('button', { name: 'Submit' }).click();`],
61+
startTime: expect.any(Number),
62+
}));
63+
});
64+
65+
test('should type', async ({ context }) => {
66+
const log = await startRecording(context);
67+
const page = await context.newPage();
68+
await page.setContent(`<input type="text" />`);
69+
70+
await page.getByRole('textbox').pressSequentially('Hello');
71+
72+
expect(log.lastAction()).toEqual(
73+
expect.objectContaining({
74+
action: expect.objectContaining({
75+
name: 'fill',
76+
selector: 'internal:role=textbox',
77+
ref: 'e2',
78+
ariaSnapshot: '- textbox [active] [ref=e2] [cursor=pointer]: Hello',
79+
}),
80+
code: [` await page.getByRole('textbox').fill('Hello');`],
81+
startTime: expect.any(Number),
82+
}));
83+
});

0 commit comments

Comments
 (0)