Skip to content

Commit

Permalink
chore(cli): add recording mode (#2579)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Jun 15, 2020
1 parent fd9b103 commit 1c7a895
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 60 deletions.
21 changes: 21 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { Playwright } from '../server/playwright';
import { BrowserType, LaunchOptions } from '../server/browserType';
import { DeviceDescriptors } from '../deviceDescriptors';
import { BrowserContextOptions } from '../browserContext';
import { setRecorderMode } from '../debug/debugController';
import { helper } from '../helper';

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

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

program
.command('record [url]')
.description('open page in browser specified via -b, --browser and start recording')
.action(function(url, command) {
record(command.parent, url);
}).on('--help', function() {
console.log('');
console.log('Examples:');
console.log('');
console.log(' $ record');
console.log(' $ -b webkit record https://example.com');
});

const browsers = [
{ initial: 'cr', name: 'Chromium', type: 'chromium' },
{ initial: 'ff', name: 'Firefox', type: 'firefox' },
Expand Down Expand Up @@ -88,6 +103,12 @@ async function open(options: Options, url: string | undefined) {
return { browser, page };
}

async function record(options: Options, url: string | undefined) {
helper.setDebugMode();
setRecorderMode();
return await open(options, url);
}

function lookupBrowserType(name: string): BrowserType {
switch (name) {
case 'chromium': return playwright.chromium!;
Expand Down
11 changes: 7 additions & 4 deletions src/debug/debugController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@ import * as frames from '../frames';
import { Page } from '../page';
import { RecorderController } from './recorderController';

export class DebugController {
private _context: BrowserContextBase;
let isRecorderMode = false;

export function setRecorderMode(): void {
isRecorderMode = true;
}

export class DebugController {
constructor(context: BrowserContextBase) {
this._context = context;
const installInFrame = async (frame: frames.Frame) => {
try {
const mainContext = await frame._mainContext();
await mainContext.debugScript();
await mainContext.createDebugScript({ console: true, record: isRecorderMode });
} catch (e) {
}
};
Expand Down
8 changes: 5 additions & 3 deletions src/debug/injected/debugScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ export default class DebugScript {
constructor() {
}

initialize(injectedScript: InjectedScript) {
this.consoleAPI = new ConsoleAPI(injectedScript);
this.recorder = new Recorder(injectedScript);
initialize(injectedScript: InjectedScript, options: { console?: boolean, record?: boolean }) {
if (options.console)
this.consoleAPI = new ConsoleAPI(injectedScript);
if (options.record)
this.recorder = new Recorder(injectedScript);
}
}
45 changes: 34 additions & 11 deletions src/debug/injected/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ import { parseSelector } from '../../common/selectorParser';

declare global {
interface Window {
recordPlaywrightAction: (action: actions.Action) => void;
performPlaywrightAction: (action: actions.Action) => Promise<void>;
recordPlaywrightAction: (action: actions.Action) => Promise<void>;
}
}

export class Recorder {
private _injectedScript: InjectedScript;
private _performingAction = false;

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

private _onClick(event: MouseEvent) {
const selector = this._buildSelector(event.target as Element);
private async _onClick(event: MouseEvent) {
if ((event.target as Element).nodeName === 'SELECT')
return;
window.recordPlaywrightAction({

// Perform action consumes this event and asks Playwright to perform it.
this._performAction(event, {
name: 'click',
selector,
selector: this._buildSelector(event.target as Element),
signals: [],
button: buttonForEvent(event),
modifiers: modifiersForEvent(event),
clickCount: event.detail
});
}

private _onInput(event: Event) {
private async _onInput(event: Event) {
const selector = this._buildSelector(event.target as Element);
if ((event.target as Element).nodeName === 'INPUT') {
const inputElement = event.target as HTMLInputElement;
if ((inputElement.type || '').toLowerCase() === 'checkbox') {
window.recordPlaywrightAction({
// Perform action consumes this event and asks Playwright to perform it.
this._performAction(event, {
name: inputElement.checked ? 'check' : 'uncheck',
selector,
signals: [],
});
return;
} else {
// Non-navigating actions are simply recorded by Playwright.
window.recordPlaywrightAction({
name: 'fill',
selector,
Expand All @@ -70,6 +76,7 @@ export class Recorder {
}
if ((event.target as Element).nodeName === 'SELECT') {
const selectElement = event.target as HTMLSelectElement;
// TODO: move this to this._performAction
window.recordPlaywrightAction({
name: 'select',
selector,
Expand All @@ -79,19 +86,29 @@ export class Recorder {
}
}

private _onKeyDown(event: KeyboardEvent) {
private async _onKeyDown(event: KeyboardEvent) {
if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape')
return;
const selector = this._buildSelector(event.target as Element);
window.recordPlaywrightAction({
this._performAction(event, {
name: 'press',
selector,
selector: this._buildSelector(event.target as Element),
signals: [],
key: event.key,
modifiers: modifiersForEvent(event),
});
}

private async _performAction(event: Event, action: actions.Action) {
// If Playwright is performing action for us, bail.
if (this._performingAction)
return;
// Consume as the first thing.
consumeEvent(event);
this._performingAction = true;
await window.performPlaywrightAction(action);
this._performingAction = false;
}

private _buildSelector(targetElement: Element): string {
const path: string[] = [];
const root = document.documentElement;
Expand Down Expand Up @@ -175,3 +192,9 @@ function buttonForEvent(event: MouseEvent): 'left' | 'middle' | 'right' {
function escapeForRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function consumeEvent(e: Event) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
2 changes: 2 additions & 0 deletions src/debug/recorderActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type ActionName =
export type ActionBase = {
signals: Signal[],
frameUrl?: string,
committed?: boolean,
}

export type ClickAction = ActionBase & {
Expand Down Expand Up @@ -74,6 +75,7 @@ export type Action = ClickAction | CheckAction | UncheckAction | FillAction | Na
export type NavigationSignal = {
name: 'navigation',
url: string,
type: 'assert' | 'await',
};

export type Signal = NavigationSignal;
Expand Down
106 changes: 86 additions & 20 deletions src/debug/recorderController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,99 @@ import * as frames from '../frames';
import { Page } from '../page';
import { Events } from '../events';
import { TerminalOutput } from './terminalOutput';
import * as dom from '../dom';

export class RecorderController {
private _page: Page;
private _output = new TerminalOutput();
private _performingAction = false;

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

this._page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => {
if (source.frame !== this._page.mainFrame())
action.frameUrl = source.frame.url();
this._output.addAction(action);
});

this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => {
if (frame.parentFrame())
return;
const action = this._output.lastAction();
if (action) {
this._output.signal({ name: 'navigation', url: frame.url() });
} else {
this._output.addAction({
name: 'navigate',
url: this._page.url(),
signals: [],
});
}
});
// Input actions that potentially lead to navigation are intercepted on the page and are
// performed by the Playwright.
this._page.exposeBinding('performPlaywrightAction',
(source, action: actions.Action) => this._performAction(source.frame, action));
// Other non-essential actions are simply being recorded.
this._page.exposeBinding('recordPlaywrightAction',
(source, action: actions.Action) => this._recordAction(source.frame, action));

this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => this._onFrameNavigated(frame));
}

private async _performAction(frame: frames.Frame, action: actions.Action) {
if (frame !== this._page.mainFrame())
action.frameUrl = frame.url();
this._performingAction = true;
this._output.addAction(action);
if (action.name === 'click') {
const { options } = toClickOptions(action);
await frame.click(action.selector, options);
}
if (action.name === 'press') {
const modifiers = toModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
await frame.press(action.selector, shortcut);
}
if (action.name === 'check')
await frame.check(action.selector);
if (action.name === 'uncheck')
await frame.uncheck(action.selector);
this._performingAction = false;
setTimeout(() => action.committed = true, 2000);
}

private async _recordAction(frame: frames.Frame, action: actions.Action) {
if (frame !== this._page.mainFrame())
action.frameUrl = frame.url();
this._output.addAction(action);
}

private _onFrameNavigated(frame: frames.Frame) {
if (frame.parentFrame())
return;
const action = this._output.lastAction();
// We only augment actions that have not been committed.
if (action && !action.committed) {
// If we hit a navigation while action is executed, we assert it. Otherwise, we await it.
this._output.signal({ name: 'navigation', url: frame.url(), type: this._performingAction ? 'assert' : 'await' });
} else {
// If navigation happens out of the blue, we just log it.
this._output.addAction({
name: 'navigate',
url: this._page.url(),
signals: [],
});
}
}
}


export function toClickOptions(action: actions.ClickAction): { method: 'click' | 'dblclick', options: dom.ClickOptions } {
let method: 'click' | 'dblclick' = 'click';
if (action.clickCount === 2)
method = 'dblclick';
const modifiers = toModifiers(action.modifiers);
const options: dom.ClickOptions = {};
if (action.button !== 'left')
options.button = action.button;
if (modifiers.length)
options.modifiers = modifiers;
if (action.clickCount > 2)
options.clickCount = action.clickCount;
return { method, options };
}

export function toModifiers(modifiers: number): ('Alt' | 'Control' | 'Meta' | 'Shift')[] {
const result: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = [];
if (modifiers & 1)
result.push('Alt');
if (modifiers & 2)
result.push('Control');
if (modifiers & 4)
result.push('Meta');
if (modifiers & 8)
result.push('Shift');
return result;
}
Loading

0 comments on commit 1c7a895

Please sign in to comment.