Skip to content

Commit 3a02f89

Browse files
authored
chore: implement lightweight recording mode (microsoft#36463)
1 parent 536c273 commit 3a02f89

11 files changed

Lines changed: 154 additions & 61 deletions

File tree

packages/injected/src/recorder/recorder.ts

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ class RecordActionTool implements RecorderTool {
245245
return;
246246

247247
const checkbox = asCheckbox(this._recorder.deepEventTarget(event));
248-
if (checkbox) {
248+
if (checkbox && event.detail === 1) {
249249
// Interestingly, inputElement.checked is reversed inside this event handler.
250250
this._performAction({
251251
name: checkbox.checked ? 'check' : 'uncheck',
@@ -335,30 +335,26 @@ class RecordActionTool implements RecorderTool {
335335
onPointerDown(event: PointerEvent) {
336336
if (this._shouldIgnoreMouseEvent(event))
337337
return;
338-
if (!this._performingActions.size)
339-
consumeEvent(event);
338+
this._consumeWhenAboutToPerform(event);
340339
}
341340

342341
onPointerUp(event: PointerEvent) {
343342
if (this._shouldIgnoreMouseEvent(event))
344343
return;
345-
if (!this._performingActions.size)
346-
consumeEvent(event);
344+
this._consumeWhenAboutToPerform(event);
347345
}
348346

349347
onMouseDown(event: MouseEvent) {
350348
if (this._shouldIgnoreMouseEvent(event))
351349
return;
352-
if (!this._performingActions.size)
353-
consumeEvent(event);
350+
this._consumeWhenAboutToPerform(event);
354351
this._activeModel = this._hoveredModel;
355352
}
356353

357354
onMouseUp(event: MouseEvent) {
358355
if (this._shouldIgnoreMouseEvent(event))
359356
return;
360-
if (!this._performingActions.size)
361-
consumeEvent(event);
357+
this._consumeWhenAboutToPerform(event);
362358
}
363359

364360
onMouseMove(event: MouseEvent) {
@@ -448,7 +444,7 @@ class RecordActionTool implements RecorderTool {
448444
// Similarly to click, trigger checkbox on key event, not input.
449445
if (event.key === ' ') {
450446
const checkbox = asCheckbox(this._recorder.deepEventTarget(event));
451-
if (checkbox) {
447+
if (checkbox && event.detail === 0) {
452448
this._performAction({
453449
name: checkbox.checked ? 'uncheck' : 'check',
454450
selector: this._activeModel!.selector,
@@ -472,7 +468,7 @@ class RecordActionTool implements RecorderTool {
472468
return;
473469

474470
// Only allow programmatic keyups, ignore user input.
475-
if (!this._expectProgrammaticKeyUp) {
471+
if (this._recorder.state.recorderMode === 'perform' && !this._expectProgrammaticKeyUp) {
476472
consumeEvent(event);
477473
return;
478474
}
@@ -486,7 +482,7 @@ class RecordActionTool implements RecorderTool {
486482
private _resetHoveredModel() {
487483
this._hoveredModel = null;
488484
this._hoveredElement = null;
489-
this._recorder.updateHighlight(null, false);
485+
this._updateHighlight(false);
490486
}
491487

492488
private _onFocus(userGesture: boolean) {
@@ -525,7 +521,8 @@ class RecordActionTool implements RecorderTool {
525521
}
526522

527523
// Consume event if action is not being executed.
528-
consumeEvent(event);
524+
if (this._recorder.state.recorderMode === 'perform')
525+
consumeEvent(event);
529526
return false;
530527
}
531528

@@ -543,23 +540,40 @@ class RecordActionTool implements RecorderTool {
543540
return true;
544541
}
545542

543+
private _consumeWhenAboutToPerform(event: Event) {
544+
if (!this._performingActions.size && this._recorder.state.recorderMode === 'perform')
545+
consumeEvent(event);
546+
}
547+
546548
private _performAction(action: actions.PerformOnRecordAction) {
547549
this._recorder.updateHighlight(null, false);
550+
551+
let promise = Promise.resolve();
552+
if (this._recorder.state.recorderMode === 'perform')
553+
promise = this._innerPerformAction(action);
554+
else
555+
this._recorder.recordAction(action);
556+
557+
if (!this._recorder.injectedScript.isUnderTest)
558+
return;
559+
560+
void promise.then(() => {
561+
// Serialize all to string as we cannot attribute console message to isolated world
562+
// in Firefox.
563+
console.error('Action performed for test: ' + JSON.stringify({ // eslint-disable-line no-console
564+
hovered: this._hoveredModel ? (this._hoveredModel as any).selector : null,
565+
active: this._activeModel ? (this._activeModel as any).selector : null,
566+
}));
567+
});
568+
}
569+
570+
private async _innerPerformAction(action: actions.PerformOnRecordAction) {
548571
this._performingActions.add(action);
549-
void this._recorder.performAction(action).then(() => {
550-
this._performingActions.delete(action);
551572

573+
return this._recorder.performAction(action).then(() => {
574+
this._performingActions.delete(action);
552575
// If that was a keyboard action, it similarly requires new selectors for active model.
553576
this._onFocus(false);
554-
555-
if (this._recorder.injectedScript.isUnderTest) {
556-
// Serialize all to string as we cannot attribute console message to isolated world
557-
// in Firefox.
558-
console.error('Action performed for test: ' + JSON.stringify({ // eslint-disable-line no-console
559-
hovered: this._hoveredModel ? (this._hoveredModel as any).selector : null,
560-
active: this._activeModel ? (this._activeModel as any).selector : null,
561-
}));
562-
}
563577
});
564578
}
565579

@@ -601,14 +615,19 @@ class RecordActionTool implements RecorderTool {
601615
if (!this._hoveredElement || !this._hoveredElement.isConnected) {
602616
this._hoveredModel = null;
603617
this._hoveredElement = null;
604-
this._recorder.updateHighlight(null, true);
618+
this._updateHighlight(true);
605619
return;
606620
}
607621
const { selector, elements } = this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName });
608622
if (this._hoveredModel && this._hoveredModel.selector === selector)
609623
return;
610624
this._hoveredModel = selector ? { selector, elements, color: HighlightColors.action } : null;
611-
this._recorder.updateHighlight(this._hoveredModel, true);
625+
this._updateHighlight(true);
626+
}
627+
628+
private _updateHighlight(userGesture: boolean) {
629+
if (this._recorder.state.recorderMode === 'perform' || this._recorder.injectedScript.isUnderTest)
630+
this._recorder.updateHighlight(this._hoveredModel, userGesture);
612631
}
613632
}
614633

@@ -1041,6 +1060,7 @@ export class Recorder {
10411060
private _stylesheet: CSSStyleSheet;
10421061
state: UIState = {
10431062
mode: 'none',
1063+
recorderMode: 'perform',
10441064
testIdAttributeName: 'data-testid',
10451065
language: 'javascript',
10461066
overlay: { offsetX: 0 },

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,7 @@ scheme.BrowserContextPauseResult = tOptional(tObject({}));
10851085
scheme.BrowserContextEnableRecorderParams = tObject({
10861086
language: tOptional(tString),
10871087
mode: tOptional(tEnum(['inspecting', 'recording'])),
1088+
recorderMode: tOptional(tEnum(['record', 'perform'])),
10881089
pauseOnNextStatement: tOptional(tBoolean),
10891090
testIdAttributeName: tOptional(tString),
10901091
launchOptions: tOptional(tAny),

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
5353
private _contextRecorder: ContextRecorder;
5454
private _omitCallTracking = false;
5555
private _currentLanguage: Language;
56+
private _recorderMode: 'record' | 'perform';
5657

5758
static async showInspector(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, recorderAppFactory: IRecorderAppFactory) {
5859
if (isUnderTest())
@@ -82,6 +83,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
8283

8384
constructor(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) {
8485
this._mode = params.mode || 'none';
86+
this._recorderMode = params.recorderMode ?? 'perform';
8587
this.handleSIGINT = params.handleSIGINT;
8688
this._contextRecorder = new ContextRecorder(context, params, {});
8789
this._context = context;
@@ -177,6 +179,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
177179
}
178180
const uiState: UIState = {
179181
mode: this._mode,
182+
recorderMode: this._recorderMode,
180183
actionPoint,
181184
actionSelector,
182185
ariaTemplate: this._highlightedElement.ariaTemplate,

packages/protocol/src/channels.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1887,6 +1887,7 @@ export type BrowserContextPauseResult = void;
18871887
export type BrowserContextEnableRecorderParams = {
18881888
language?: string,
18891889
mode?: 'inspecting' | 'recording',
1890+
recorderMode?: 'record' | 'perform',
18901891
pauseOnNextStatement?: boolean,
18911892
testIdAttributeName?: string,
18921893
launchOptions?: any,
@@ -1900,6 +1901,7 @@ export type BrowserContextEnableRecorderParams = {
19001901
export type BrowserContextEnableRecorderOptions = {
19011902
language?: string,
19021903
mode?: 'inspecting' | 'recording',
1904+
recorderMode?: 'record' | 'perform',
19031905
pauseOnNextStatement?: boolean,
19041906
testIdAttributeName?: string,
19051907
launchOptions?: any,

packages/protocol/src/protocol.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,11 @@ BrowserContext:
13661366
literals:
13671367
- inspecting
13681368
- recording
1369+
recorderMode:
1370+
type: enum?
1371+
literals:
1372+
- record
1373+
- perform
13691374
pauseOnNextStatement: boolean?
13701375
testIdAttributeName: string?
13711376
launchOptions: json?

packages/recorder/src/recorderTypes.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export type OverlayState = {
5353

5454
export type UIState = {
5555
mode: Mode;
56+
recorderMode: 'record' | 'perform';
5657
actionPoint?: Point;
5758
actionSelector?: string;
5859
ariaTemplate?: AriaTemplateNode;

packages/trace-viewer/src/ui/snapshotTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export const InspectModeController: React.FunctionComponent<{
254254
const ariaTemplate = parsedSnapshot?.errors.length === 0 ? parsedSnapshot.fragment : undefined;
255255
recorder.setUIState({
256256
mode: isInspecting ? 'inspecting' : 'none',
257+
recorderMode: 'perform',
257258
actionSelector,
258259
ariaTemplate,
259260
language: sdkLanguage,

tests/library/inspector/cli-codegen-1.spec.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { test, expect } from './inspectorTest';
17+
import { test, expect, matrixDescribe } from './inspectorTest';
1818
import type { ConsoleMessage } from 'playwright';
1919

20-
test.describe('cli codegen', () => {
20+
matrixDescribe<('record' | 'perform')>('cli codegen', ['record', 'perform'], recorderMode => {
2121
test.skip(({ mode }) => mode !== 'default');
22+
test.use({ recorderMode });
2223

2324
test('should click', async ({ openRecorder }) => {
2425
const { page, recorder } = await openRecorder();
@@ -94,7 +95,7 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync()
9495
});
9596

9697
test('should click twice', async ({ openRecorder }) => {
97-
const { recorder } = await openRecorder();
98+
const { page, recorder } = await openRecorder();
9899

99100
await recorder.setContentAndWait(`<button onclick="console.log('click')">Submit</button>`);
100101

@@ -106,6 +107,9 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync()
106107
recorder.trustedClick(),
107108
]);
108109

110+
// Do not trigger double click.
111+
await page.waitForTimeout(200);
112+
109113
const [sources] = await Promise.all([
110114
recorder.waitForOutput('JavaScript', `click();\n await`),
111115
recorder.trustedClick(),
@@ -128,6 +132,9 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync()
128132
recorder.trustedClick(),
129133
]);
130134

135+
// Do not trigger double click.
136+
await page.waitForTimeout(200);
137+
131138
await Promise.all([
132139
recorder.waitForOutput('JavaScript', `click();\n await`),
133140
recorder.trustedClick(),
@@ -458,7 +465,7 @@ await page.Locator("#input").FillAsync(\"てすと\");`);
458465
expect(sources.get('C#')!.text).toContain(`
459466
await page.GetByRole(AriaRole.Textbox).PressAsync("Shift+Enter");`);
460467

461-
expect(messages[0].text()).toBe('press');
468+
expect(messages.map(m => m.text())).toContain('press');
462469
});
463470

464471
test('should update selected element after pressing Tab', async ({ openRecorder }) => {
@@ -526,7 +533,7 @@ await page.GetByRole(AriaRole.Textbox).PressAsync("Shift+Enter");`);
526533
]);
527534
expect(sources.get('JavaScript')!.text).toContain(`
528535
await page.getByRole('textbox').press('ArrowDown');`);
529-
expect(messages[0].text()).toBe('press:ArrowDown');
536+
expect(messages.map(m => m.text())).toContain('press:ArrowDown');
530537
});
531538

532539
test('should emit single keyup on ArrowDown', async ({ openRecorder }) => {
@@ -624,6 +631,28 @@ await page.Locator("#checkbox").CheckAsync();`);
624631
expect(message.text()).toBe('true');
625632
});
626633

634+
test('should check with keyboard after hover', async ({ openRecorder }) => {
635+
const { page, recorder } = await openRecorder();
636+
637+
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" name="accept" onchange="console.log(checkbox.checked)"></input>`);
638+
639+
await recorder.hoverOverElement('input');
640+
await page.focus('input');
641+
642+
const [sources] = await Promise.all([
643+
recorder.waitForOutput('JavaScript', 'check'),
644+
page.keyboard.press('Space')
645+
]);
646+
647+
expect(sources.get('JavaScript')!.text).toContain(`
648+
await page.locator('#checkbox').check();`);
649+
650+
const sources2 = await recorder.waitForOutput('JavaScript', 'check');
651+
expect(sources2.get('JavaScript')!.text).not.toContain(`
652+
await page.locator('#checkbox').check();
653+
await page.locator('#checkbox').check();`);
654+
});
655+
627656
test('should uncheck', async ({ openRecorder }) => {
628657
const { page, recorder } = await openRecorder();
629658

@@ -731,6 +760,8 @@ await page.Locator(\"#age\").SelectOptionAsync(new[] { \"2\" });`);
731760
});
732761

733762
test('should await popup', async ({ openRecorder }) => {
763+
test.skip(recorderMode === 'record', 'Navigation is dispatched concurrently (before click is recorded)');
764+
734765
const { page, recorder } = await openRecorder();
735766
await recorder.setContentAndWait('<a target=_blank rel=noopener href="about:blank">link</a>');
736767

@@ -773,6 +804,8 @@ var page1 = await page.RunAndWaitForPopupAsync(async () =>
773804
});
774805

775806
test('should attribute navigation to click', async ({ openRecorder }) => {
807+
test.skip(recorderMode === 'record', 'Navigation is dispatched concurrently (before click is recorded)');
808+
776809
const { page, recorder } = await openRecorder();
777810

778811
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
@@ -828,6 +861,8 @@ await page.GetByText("link").ClickAsync();`);
828861
});
829862

830863
test('should attribute navigation to press/fill', async ({ openRecorder }) => {
864+
test.skip(recorderMode === 'record', 'Navigation is dispatched concurrently (before press/fill is recorded)');
865+
831866
const { page, recorder } = await openRecorder();
832867

833868
await recorder.setContentAndWait(`<input /><script>document.querySelector('input').addEventListener('input', () => window.location.href = 'about:blank#foo');</script>`);

0 commit comments

Comments
 (0)