mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore(cli): add recording mode (#2579)
This commit is contained in:
parent
fd9b1031fa
commit
1c7a8952b9
@ -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!;
|
||||
|
@ -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) {
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user