Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(cli): add recording mode #2579

Merged
merged 1 commit into from
Jun 15, 2020
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
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