chore(cli): add recording mode (#2579)

This commit is contained in:
Pavel Feldman 2020-06-15 15:27:03 -07:00 committed by GitHub
parent fd9b1031fa
commit 1c7a8952b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 172 additions and 59 deletions

View File

@ -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']);
@ -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' },
@ -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!;

View File

@ -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) {
}
};

View File

@ -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);
}
}

View File

@ -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;
@ -35,13 +37,14 @@ 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),
@ -49,17 +52,20 @@ export class Recorder {
});
}
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,
@ -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,
@ -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;
@ -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();
}

View File

@ -23,6 +23,7 @@ export type ActionName =
export type ActionBase = {
signals: Signal[],
frameUrl?: string,
committed?: boolean,
}
export type ClickAction = ActionBase & {
@ -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;

View File

@ -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);
});
// 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) => {
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: [],
});
}
});
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;
}

View File

@ -17,6 +17,9 @@
import * as dom from '../dom';
import { Formatter, formatColors } from '../utils/formatter';
import { Action, NavigationSignal, actionTitle } from './recorderActions';
import { toModifiers } from './recorderController';
const { cst, cmt, fnc, kwd, prp, str } = formatColors;
export class TerminalOutput {
private _lastAction: Action | undefined;
@ -24,9 +27,9 @@ export class TerminalOutput {
constructor() {
const formatter = new Formatter();
const { cst, fnc, kwd, str } = formatColors;
formatter.add(`
${kwd('const')} ${cst('assert')} = ${fnc('require')}(${str('assert')});
${kwd('const')} { ${cst('chromium')}, ${cst('firefox')}, ${cst('webkit')} } = ${fnc('require')}(${str('playwright')});
(${kwd('async')}() => {
@ -38,6 +41,7 @@ export class TerminalOutput {
}
addAction(action: Action) {
// We augment last action based on the type.
let eraseLastAction = false;
if (this._lastAction && action.name === 'fill' && this._lastAction.name === 'fill') {
if (action.selector === this._lastAction.selector)
@ -57,9 +61,11 @@ export class TerminalOutput {
}
_printAction(action: Action, eraseLastAction: boolean) {
// We erase terminating `})();` at all times.
let eraseLines = 1;
if (eraseLastAction && this._lastActionText)
eraseLines += this._lastActionText.split('\n').length;
// And we erase the last action too if augmenting.
for (let i = 0; i < eraseLines; ++i)
process.stdout.write('\u001B[F\u001B[2K');
@ -82,23 +88,24 @@ export class TerminalOutput {
private _generateAction(action: Action): string {
const formatter = new Formatter(2);
const { cst, cmt, fnc, kwd, prp, str } = formatColors;
formatter.newLine();
formatter.add(cmt(actionTitle(action)));
let navigationSignal: NavigationSignal | undefined;
if (action.name !== 'navigate' && action.signals && action.signals.length)
navigationSignal = action.signals[action.signals.length - 1];
if (navigationSignal) {
const waitForNavigation = navigationSignal && navigationSignal.type === 'await';
const assertNavigation = navigationSignal && navigationSignal.type === 'assert';
if (waitForNavigation) {
formatter.add(`${kwd('await')} ${cst('Promise')}.${fnc('all')}([
${cst('page')}.${fnc('waitForNavigation')}({ ${prp('url')}: ${str(navigationSignal.url)} }),`);
${cst('page')}.${fnc('waitForNavigation')}({ ${prp('url')}: ${str(navigationSignal!.url)} }),`);
}
const subject = action.frameUrl ?
`${cst('page')}.${fnc('frame')}(${formatObject({ url: action.frameUrl })})` : cst('page');
const prefix = navigationSignal ? '' : kwd('await') + ' ';
const suffix = navigationSignal ? '' : ';';
const prefix = waitForNavigation ? '' : kwd('await') + ' ';
const suffix = waitForNavigation ? '' : ';';
switch (action.name) {
case 'click': {
let method = 'click';
@ -138,8 +145,10 @@ export class TerminalOutput {
formatter.add(`${prefix}${subject}.${fnc('select')}(${str(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})${suffix}`);
break;
}
if (navigationSignal)
if (waitForNavigation)
formatter.add(`]);`);
else if (assertNavigation)
formatter.add(` ${cst('assert')}.${fnc('equal')}(${cst('page')}.${fnc('url')}(), ${str(navigationSignal!.url)});`);
return formatter.format();
}
}
@ -152,7 +161,6 @@ function formatOptions(value: any): string {
}
function formatObject(value: any): string {
const { prp, str } = formatColors;
if (typeof value === 'string')
return str(value);
if (Array.isArray(value))
@ -169,15 +177,3 @@ function formatObject(value: any): string {
return String(value);
}
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;
}

View File

@ -94,7 +94,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
return this._injectedScriptPromise;
}
debugScript(): Promise<js.JSHandle<DebugScript> | undefined> {
createDebugScript(options: { record?: boolean, console?: boolean }): Promise<js.JSHandle<DebugScript> | undefined> {
if (!helper.isDebugMode())
return Promise.resolve(undefined);
@ -102,7 +102,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
const source = `new (${debugScriptSource.source})()`;
this._debugScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId)).then(async debugScript => {
const injectedScript = await this.injectedScript();
await debugScript.evaluate((debugScript: DebugScript, injectedScript) => debugScript.initialize(injectedScript), injectedScript);
await debugScript.evaluate((debugScript: DebugScript, { injectedScript, options }) => debugScript.initialize(injectedScript, options), { injectedScript, options });
return debugScript;
}).catch(e => undefined);
}