From 1c7a8952b9897cbeee592c2314c48e13599cf9f5 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 15 Jun 2020 15:27:03 -0700 Subject: [PATCH] chore(cli): add recording mode (#2579) --- src/cli/index.ts | 21 ++++++ src/debug/debugController.ts | 11 ++-- src/debug/injected/debugScript.ts | 8 ++- src/debug/injected/recorder.ts | 45 +++++++++---- src/debug/recorderActions.ts | 2 + src/debug/recorderController.ts | 106 ++++++++++++++++++++++++------ src/debug/terminalOutput.ts | 36 +++++----- src/dom.ts | 4 +- 8 files changed, 173 insertions(+), 60 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index a82022f903802..e3abc9a63da55 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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!; diff --git a/src/debug/debugController.ts b/src/debug/debugController.ts index 5cdf1360b8964..64c5bc8ba120a 100644 --- a/src/debug/debugController.ts +++ b/src/debug/debugController.ts @@ -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) { } }; diff --git a/src/debug/injected/debugScript.ts b/src/debug/injected/debugScript.ts index a126f07d27b87..7609389e1b1a0 100644 --- a/src/debug/injected/debugScript.ts +++ b/src/debug/injected/debugScript.ts @@ -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); } } diff --git a/src/debug/injected/recorder.ts b/src/debug/injected/recorder.ts index e5f738a44f2fa..4d8ac9ee8a90a 100644 --- a/src/debug/injected/recorder.ts +++ b/src/debug/injected/recorder.ts @@ -20,12 +20,14 @@ import { parseSelector } from '../../common/selectorParser'; declare global { interface Window { - recordPlaywrightAction: (action: actions.Action) => void; + performPlaywrightAction: (action: actions.Action) => Promise; + recordPlaywrightAction: (action: actions.Action) => Promise; } } 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(); +} diff --git a/src/debug/recorderActions.ts b/src/debug/recorderActions.ts index 47f065285d283..e2c85e3759d4f 100644 --- a/src/debug/recorderActions.ts +++ b/src/debug/recorderActions.ts @@ -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; diff --git a/src/debug/recorderController.ts b/src/debug/recorderController.ts index 088286eab0d4a..da5fba9afffec 100644 --- a/src/debug/recorderController.ts +++ b/src/debug/recorderController.ts @@ -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; +} diff --git a/src/debug/terminalOutput.ts b/src/debug/terminalOutput.ts index a2113374e768b..7bf7ece7aced4 100644 --- a/src/debug/terminalOutput.ts +++ b/src/debug/terminalOutput.ts @@ -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; -} diff --git a/src/dom.ts b/src/dom.ts index 9b549689eee3e..e97f9d19fa0ec 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -94,7 +94,7 @@ export class FrameExecutionContext extends js.ExecutionContext { return this._injectedScriptPromise; } - debugScript(): Promise | undefined> { + createDebugScript(options: { record?: boolean, console?: boolean }): Promise | 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); }