Skip to content

Commit 1c7a895

Browse files
authored
chore(cli): add recording mode (microsoft#2579)
1 parent fd9b103 commit 1c7a895

File tree

8 files changed

+173
-60
lines changed

8 files changed

+173
-60
lines changed

src/cli/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { Playwright } from '../server/playwright';
2323
import { BrowserType, LaunchOptions } from '../server/browserType';
2424
import { DeviceDescriptors } from '../deviceDescriptors';
2525
import { BrowserContextOptions } from '../browserContext';
26+
import { setRecorderMode } from '../debug/debugController';
27+
import { helper } from '../helper';
2628

2729
const playwright = new Playwright(__dirname, require('../../browsers.json')['browsers']);
2830

@@ -45,6 +47,19 @@ program
4547
console.log(' $ -b webkit open https://example.com');
4648
});
4749

50+
program
51+
.command('record [url]')
52+
.description('open page in browser specified via -b, --browser and start recording')
53+
.action(function(url, command) {
54+
record(command.parent, url);
55+
}).on('--help', function() {
56+
console.log('');
57+
console.log('Examples:');
58+
console.log('');
59+
console.log(' $ record');
60+
console.log(' $ -b webkit record https://example.com');
61+
});
62+
4863
const browsers = [
4964
{ initial: 'cr', name: 'Chromium', type: 'chromium' },
5065
{ initial: 'ff', name: 'Firefox', type: 'firefox' },
@@ -88,6 +103,12 @@ async function open(options: Options, url: string | undefined) {
88103
return { browser, page };
89104
}
90105

106+
async function record(options: Options, url: string | undefined) {
107+
helper.setDebugMode();
108+
setRecorderMode();
109+
return await open(options, url);
110+
}
111+
91112
function lookupBrowserType(name: string): BrowserType {
92113
switch (name) {
93114
case 'chromium': return playwright.chromium!;

src/debug/debugController.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,18 @@ import * as frames from '../frames';
2020
import { Page } from '../page';
2121
import { RecorderController } from './recorderController';
2222

23-
export class DebugController {
24-
private _context: BrowserContextBase;
23+
let isRecorderMode = false;
24+
25+
export function setRecorderMode(): void {
26+
isRecorderMode = true;
27+
}
2528

29+
export class DebugController {
2630
constructor(context: BrowserContextBase) {
27-
this._context = context;
2831
const installInFrame = async (frame: frames.Frame) => {
2932
try {
3033
const mainContext = await frame._mainContext();
31-
await mainContext.debugScript();
34+
await mainContext.createDebugScript({ console: true, record: isRecorderMode });
3235
} catch (e) {
3336
}
3437
};

src/debug/injected/debugScript.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ export default class DebugScript {
2525
constructor() {
2626
}
2727

28-
initialize(injectedScript: InjectedScript) {
29-
this.consoleAPI = new ConsoleAPI(injectedScript);
30-
this.recorder = new Recorder(injectedScript);
28+
initialize(injectedScript: InjectedScript, options: { console?: boolean, record?: boolean }) {
29+
if (options.console)
30+
this.consoleAPI = new ConsoleAPI(injectedScript);
31+
if (options.record)
32+
this.recorder = new Recorder(injectedScript);
3133
}
3234
}

src/debug/injected/recorder.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ import { parseSelector } from '../../common/selectorParser';
2020

2121
declare global {
2222
interface Window {
23-
recordPlaywrightAction: (action: actions.Action) => void;
23+
performPlaywrightAction: (action: actions.Action) => Promise<void>;
24+
recordPlaywrightAction: (action: actions.Action) => Promise<void>;
2425
}
2526
}
2627

2728
export class Recorder {
2829
private _injectedScript: InjectedScript;
30+
private _performingAction = false;
2931

3032
constructor(injectedScript: InjectedScript) {
3133
this._injectedScript = injectedScript;
@@ -35,31 +37,35 @@ export class Recorder {
3537
document.addEventListener('keydown', event => this._onKeyDown(event), true);
3638
}
3739

38-
private _onClick(event: MouseEvent) {
39-
const selector = this._buildSelector(event.target as Element);
40+
private async _onClick(event: MouseEvent) {
4041
if ((event.target as Element).nodeName === 'SELECT')
4142
return;
42-
window.recordPlaywrightAction({
43+
44+
// Perform action consumes this event and asks Playwright to perform it.
45+
this._performAction(event, {
4346
name: 'click',
44-
selector,
47+
selector: this._buildSelector(event.target as Element),
4548
signals: [],
4649
button: buttonForEvent(event),
4750
modifiers: modifiersForEvent(event),
4851
clickCount: event.detail
4952
});
5053
}
5154

52-
private _onInput(event: Event) {
55+
private async _onInput(event: Event) {
5356
const selector = this._buildSelector(event.target as Element);
5457
if ((event.target as Element).nodeName === 'INPUT') {
5558
const inputElement = event.target as HTMLInputElement;
5659
if ((inputElement.type || '').toLowerCase() === 'checkbox') {
57-
window.recordPlaywrightAction({
60+
// Perform action consumes this event and asks Playwright to perform it.
61+
this._performAction(event, {
5862
name: inputElement.checked ? 'check' : 'uncheck',
5963
selector,
6064
signals: [],
6165
});
66+
return;
6267
} else {
68+
// Non-navigating actions are simply recorded by Playwright.
6369
window.recordPlaywrightAction({
6470
name: 'fill',
6571
selector,
@@ -70,6 +76,7 @@ export class Recorder {
7076
}
7177
if ((event.target as Element).nodeName === 'SELECT') {
7278
const selectElement = event.target as HTMLSelectElement;
79+
// TODO: move this to this._performAction
7380
window.recordPlaywrightAction({
7481
name: 'select',
7582
selector,
@@ -79,19 +86,29 @@ export class Recorder {
7986
}
8087
}
8188

82-
private _onKeyDown(event: KeyboardEvent) {
89+
private async _onKeyDown(event: KeyboardEvent) {
8390
if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape')
8491
return;
85-
const selector = this._buildSelector(event.target as Element);
86-
window.recordPlaywrightAction({
92+
this._performAction(event, {
8793
name: 'press',
88-
selector,
94+
selector: this._buildSelector(event.target as Element),
8995
signals: [],
9096
key: event.key,
9197
modifiers: modifiersForEvent(event),
9298
});
9399
}
94100

101+
private async _performAction(event: Event, action: actions.Action) {
102+
// If Playwright is performing action for us, bail.
103+
if (this._performingAction)
104+
return;
105+
// Consume as the first thing.
106+
consumeEvent(event);
107+
this._performingAction = true;
108+
await window.performPlaywrightAction(action);
109+
this._performingAction = false;
110+
}
111+
95112
private _buildSelector(targetElement: Element): string {
96113
const path: string[] = [];
97114
const root = document.documentElement;
@@ -175,3 +192,9 @@ function buttonForEvent(event: MouseEvent): 'left' | 'middle' | 'right' {
175192
function escapeForRegex(text: string): string {
176193
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
177194
}
195+
196+
function consumeEvent(e: Event) {
197+
e.preventDefault();
198+
e.stopPropagation();
199+
e.stopImmediatePropagation();
200+
}

src/debug/recorderActions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type ActionName =
2323
export type ActionBase = {
2424
signals: Signal[],
2525
frameUrl?: string,
26+
committed?: boolean,
2627
}
2728

2829
export type ClickAction = ActionBase & {
@@ -74,6 +75,7 @@ export type Action = ClickAction | CheckAction | UncheckAction | FillAction | Na
7475
export type NavigationSignal = {
7576
name: 'navigation',
7677
url: string,
78+
type: 'assert' | 'await',
7779
};
7880

7981
export type Signal = NavigationSignal;

src/debug/recorderController.ts

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,33 +19,99 @@ import * as frames from '../frames';
1919
import { Page } from '../page';
2020
import { Events } from '../events';
2121
import { TerminalOutput } from './terminalOutput';
22+
import * as dom from '../dom';
2223

2324
export class RecorderController {
2425
private _page: Page;
2526
private _output = new TerminalOutput();
27+
private _performingAction = false;
2628

2729
constructor(page: Page) {
2830
this._page = page;
2931

30-
this._page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => {
31-
if (source.frame !== this._page.mainFrame())
32-
action.frameUrl = source.frame.url();
33-
this._output.addAction(action);
34-
});
35-
36-
this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => {
37-
if (frame.parentFrame())
38-
return;
39-
const action = this._output.lastAction();
40-
if (action) {
41-
this._output.signal({ name: 'navigation', url: frame.url() });
42-
} else {
43-
this._output.addAction({
44-
name: 'navigate',
45-
url: this._page.url(),
46-
signals: [],
47-
});
48-
}
49-
});
32+
// Input actions that potentially lead to navigation are intercepted on the page and are
33+
// performed by the Playwright.
34+
this._page.exposeBinding('performPlaywrightAction',
35+
(source, action: actions.Action) => this._performAction(source.frame, action));
36+
// Other non-essential actions are simply being recorded.
37+
this._page.exposeBinding('recordPlaywrightAction',
38+
(source, action: actions.Action) => this._recordAction(source.frame, action));
39+
40+
this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => this._onFrameNavigated(frame));
41+
}
42+
43+
private async _performAction(frame: frames.Frame, action: actions.Action) {
44+
if (frame !== this._page.mainFrame())
45+
action.frameUrl = frame.url();
46+
this._performingAction = true;
47+
this._output.addAction(action);
48+
if (action.name === 'click') {
49+
const { options } = toClickOptions(action);
50+
await frame.click(action.selector, options);
51+
}
52+
if (action.name === 'press') {
53+
const modifiers = toModifiers(action.modifiers);
54+
const shortcut = [...modifiers, action.key].join('+');
55+
await frame.press(action.selector, shortcut);
56+
}
57+
if (action.name === 'check')
58+
await frame.check(action.selector);
59+
if (action.name === 'uncheck')
60+
await frame.uncheck(action.selector);
61+
this._performingAction = false;
62+
setTimeout(() => action.committed = true, 2000);
63+
}
64+
65+
private async _recordAction(frame: frames.Frame, action: actions.Action) {
66+
if (frame !== this._page.mainFrame())
67+
action.frameUrl = frame.url();
68+
this._output.addAction(action);
69+
}
70+
71+
private _onFrameNavigated(frame: frames.Frame) {
72+
if (frame.parentFrame())
73+
return;
74+
const action = this._output.lastAction();
75+
// We only augment actions that have not been committed.
76+
if (action && !action.committed) {
77+
// If we hit a navigation while action is executed, we assert it. Otherwise, we await it.
78+
this._output.signal({ name: 'navigation', url: frame.url(), type: this._performingAction ? 'assert' : 'await' });
79+
} else {
80+
// If navigation happens out of the blue, we just log it.
81+
this._output.addAction({
82+
name: 'navigate',
83+
url: this._page.url(),
84+
signals: [],
85+
});
86+
}
5087
}
5188
}
89+
90+
91+
export function toClickOptions(action: actions.ClickAction): { method: 'click' | 'dblclick', options: dom.ClickOptions } {
92+
let method: 'click' | 'dblclick' = 'click';
93+
if (action.clickCount === 2)
94+
method = 'dblclick';
95+
const modifiers = toModifiers(action.modifiers);
96+
const options: dom.ClickOptions = {};
97+
if (action.button !== 'left')
98+
options.button = action.button;
99+
if (modifiers.length)
100+
options.modifiers = modifiers;
101+
if (action.clickCount > 2)
102+
options.clickCount = action.clickCount;
103+
return { method, options };
104+
}
105+
106+
export function toModifiers(modifiers: number): ('Alt' | 'Control' | 'Meta' | 'Shift')[] {
107+
const result: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = [];
108+
if (modifiers & 1)
109+
result.push('Alt');
110+
if (modifiers & 2)
111+
result.push('Control');
112+
if (modifiers & 4)
113+
result.push('Meta');
114+
if (modifiers & 8)
115+
result.push('Shift');
116+
return result;
117+
}

0 commit comments

Comments
 (0)