diff --git a/src/page.ts b/src/page.ts index 8dfa8aa0f0..eed866973a 100644 --- a/src/page.ts +++ b/src/page.ts @@ -32,6 +32,7 @@ import { EventEmitter } from 'events'; import { FileChooser } from './fileChooser'; import { logError, InnerLogger } from './logger'; import { ProgressController } from './progress'; +import { Recorder } from './recorder/recorder'; export interface PageDelegate { readonly rawMouse: input.RawMouse; @@ -503,6 +504,10 @@ export class Page extends EventEmitter { return this.mainFrame().uncheck(selector, options); } + async _startRecordingUser() { + new Recorder(this).start(); + } + async waitForTimeout(timeout: number) { await this.mainFrame().waitForTimeout(timeout); } diff --git a/src/recorder/actions.ts b/src/recorder/actions.ts new file mode 100644 index 0000000000..33fb33a3f7 --- /dev/null +++ b/src/recorder/actions.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ActionName = + 'goto' | + 'fill' | + 'press' | + 'select'; + +export type ClickAction = { + name: 'click', + signals?: Signal[], + selector: string, + button: 'left' | 'middle' | 'right', + modifiers: number, + clickCount: number, +}; + +export type CheckAction = { + name: 'check', + signals?: Signal[], + selector: string, +}; + +export type UncheckAction = { + name: 'uncheck', + signals?: Signal[], + selector: string, +}; + +export type FillAction = { + name: 'fill', + signals?: Signal[], + selector: string, + text: string +}; + +export type NavigateAction = { + name: 'navigate', + signals?: Signal[], + url: string +}; + +export type PressAction = { + name: 'press', + signals?: Signal[], + selector: string, + key: string +}; + +export type SelectAction = { + name: 'select', + signals?: Signal[], + selector: string, + options: string[], +}; + +export type Action = ClickAction | CheckAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction; + +// Signals. + +export type NavigationSignal = { + name: 'navigation', + url: string, +}; + +export type Signal = NavigationSignal; + +export function actionTitle(action: Action): string { + switch (action.name) { + case 'check': + return 'Check'; + case 'uncheck': + return 'Uncheck'; + case 'click': { + if (action.clickCount === 1) + return 'Click'; + if (action.clickCount === 2) + return 'Double click'; + if (action.clickCount === 3) + return 'Triple click'; + return `${action.clickCount}× click`; + } + case 'fill': + return 'Fill'; + case 'navigate': + return 'Navigate'; + case 'press': + return 'Press'; + case 'select': + return 'Select'; + } +} diff --git a/src/recorder/formatter.ts b/src/recorder/formatter.ts new file mode 100644 index 0000000000..38003e38b6 --- /dev/null +++ b/src/recorder/formatter.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class Formatter { + private _baseIndent: string; + private _lines: string[] = []; + + constructor(indent: number = 2) { + this._baseIndent = [...Array(indent + 1)].join(' '); + } + + prepend(text: string) { + this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines); + } + + add(text: string) { + this._lines.push(...text.trim().split('\n').map(line => line.trim())); + } + + newLine() { + this._lines.push(''); + } + + format(): string { + let spaces = ''; + let previousLine = ''; + return this._lines.map((line: string) => { + if (line === '') + return line; + if (line.startsWith('}') || line.startsWith(']')) + spaces = spaces.substring(this._baseIndent.length); + + const extraSpaces = /^(for|while|if).*\(.*\)$/.test(previousLine) ? this._baseIndent : ''; + previousLine = line; + + line = spaces + extraSpaces + line; + if (line.endsWith('{') || line.endsWith('[')) + spaces += this._baseIndent; + return line; + }).join('\n'); + } +} + +type StringFormatter = (s: string) => string; + +export const formatColors: { cst: StringFormatter; kwd: StringFormatter; fnc: StringFormatter; prp: StringFormatter, str: StringFormatter; cmt: StringFormatter } = { + cst: text => `\u001b[38;5;72m${text}\x1b[0m`, + kwd: text => `\u001b[38;5;39m${text}\x1b[0m`, + fnc: text => `\u001b[38;5;223m${text}\x1b[0m`, + prp: text => `\u001b[38;5;159m${text}\x1b[0m`, + str: text => `\u001b[38;5;130m${quote(text)}\x1b[0m`, + cmt: text => `// \u001b[38;5;23m${text}\x1b[0m` +}; + +function quote(text: string, char: string = '\'') { + if (char === '\'') + return char + text.replace(/[']/g, '\\\'').replace(/\\/g, '\\\\') + char; + if (char === '"') + return char + text.replace(/["]/g, '\\"').replace(/\\/g, '\\\\') + char; + if (char === '`') + return char + text.replace(/[`]/g, '\\`').replace(/\\/g, '\\\\') + char; + throw new Error('Invalid escape char'); +} diff --git a/src/recorder/recorder.ts b/src/recorder/recorder.ts new file mode 100644 index 0000000000..73a5f9b4e6 --- /dev/null +++ b/src/recorder/recorder.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as frames from '../frames'; +import { Page } from '../page'; +import { Script } from './script'; +import { Events } from '../events'; +import * as actions from './actions'; + +declare global { + interface Window { + recordPlaywrightAction: (action: actions.Action) => void; + } +} + +export class Recorder { + private _page: Page; + private _script = new Script(); + + constructor(page: Page) { + this._page = page; + } + + start() { + this._script.addAction({ + name: 'navigate', + url: this._page.url() + }); + this._printScript(); + + this._page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => { + this._script.addAction(action); + this._printScript(); + }); + + this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => { + if (frame.parentFrame()) + return; + const action = this._script.lastAction(); + if (action) { + action.signals = action.signals || []; + action.signals.push({ name: 'navigation', url: frame.url() }); + } + this._printScript(); + }); + + const injectedScript = () => { + if (document.readyState === 'complete') + addListeners(); + else + document.addEventListener('load', addListeners); + + function addListeners() { + document.addEventListener('click', (event: MouseEvent) => { + const selector = buildSelector(event.target as Node); + if ((event.target as Element).nodeName === 'SELECT') + return; + window.recordPlaywrightAction({ + name: 'click', + selector, + button: buttonForEvent(event), + modifiers: modifiersForEvent(event), + clickCount: event.detail + }); + }, true); + document.addEventListener('input', (event: Event) => { + const selector = buildSelector(event.target as Node); + if ((event.target as Element).nodeName === 'INPUT') { + const inputElement = event.target as HTMLInputElement; + if ((inputElement.type || '').toLowerCase() === 'checkbox') { + window.recordPlaywrightAction({ + name: inputElement.checked ? 'check' : 'uncheck', + selector, + }); + } else { + window.recordPlaywrightAction({ + name: 'fill', + selector, + text: (event.target! as HTMLInputElement).value, + }); + } + } + if ((event.target as Element).nodeName === 'SELECT') { + const selectElement = event.target as HTMLSelectElement; + window.recordPlaywrightAction({ + name: 'select', + selector, + options: [...selectElement.selectedOptions].map(option => option.value), + }); + } + }, true); + document.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape') + return; + const selector = buildSelector(event.target as Node); + window.recordPlaywrightAction({ + name: 'press', + selector, + key: event.key, + }); + }, true); + } + + function buildSelector(node: Node): string { + const element = node as Element; + for (const attribute of ['data-testid', 'aria-label', 'id', 'data-test-id', 'data-test']) { + if (element.hasAttribute(attribute)) + return `[${attribute}=${element.getAttribute(attribute)}]`; + } + if (element.nodeName === 'INPUT') + return `[input name=${element.getAttribute('name')}]`; + return `text="${element.textContent}"`; + } + + function modifiersForEvent(event: MouseEvent | KeyboardEvent): number { + return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0); + } + + function buttonForEvent(event: MouseEvent): 'left' | 'middle' | 'right' { + switch (event.which) { + case 1: return 'left'; + case 2: return 'middle'; + case 3: return 'right'; + } + return 'left'; + } + }; + this._page.addInitScript(injectedScript); + this._page.evaluate(injectedScript); + } + + _printScript() { + console.log('\x1Bc'); // eslint-disable-line no-console + console.log(this._script.generate('chromium')); // eslint-disable-line no-console + } +} diff --git a/src/recorder/script.ts b/src/recorder/script.ts new file mode 100644 index 0000000000..32a9a0b158 --- /dev/null +++ b/src/recorder/script.ts @@ -0,0 +1,151 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as dom from '../dom'; +import { Formatter, formatColors } from './formatter'; +import { Action, NavigationSignal, actionTitle } from './actions'; + +export class Script { + private _actions: Action[] = []; + + addAction(action: Action) { + this._actions.push(action); + } + + lastAction(): Action | undefined { + return this._actions[this._actions.length - 1]; + } + + private _compact(): Action[] { + const result: Action[] = []; + let lastAction: Action | undefined; + for (const action of this._actions) { + if (lastAction && action.name === 'fill' && lastAction.name === 'fill') { + if (action.selector === lastAction.selector) + result.pop(); + } + if (lastAction && action.name === 'click' && lastAction.name === 'click') { + if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount) + result.pop(); + } + for (const name of ['check', 'uncheck']) { + if (lastAction && action.name === name && lastAction.name === 'click') { + if ((action as any).selector === (lastAction as any).selector) + result.pop(); + } + } + lastAction = action; + result.push(action); + } + return result; + } + + generate(browserType: string) { + const formatter = new Formatter(); + const { cst, cmt, fnc, kwd, prp, str } = formatColors; + formatter.add(` + ${kwd('const')} { ${cst('chromium')}. ${cst('firefox')}, ${cst('webkit')} } = ${fnc('require')}(${str('playwright')}); + + (${kwd('async')}() => { + ${kwd('const')} ${cst('browser')} = ${kwd('await')} ${cst(`${browserType}`)}.${fnc('launch')}(); + ${kwd('const')} ${cst('page')} = ${kwd('await')} ${cst('browser')}.${fnc('newPage')}(); + `); + for (const action of this._compact()) { + 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) { + formatter.add(`${kwd('await')} ${cst('Promise')}.${fnc('all')}([ + ${cst('page')}.${fnc('waitForNavigation')}({ ${prp('url')}: ${str(navigationSignal.url)} }),`); + } + const prefix = navigationSignal ? '' : kwd('await') + ' '; + const suffix = navigationSignal ? '' : ';'; + if (action.name === 'click') { + let method = '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; + const optionsString = formatOptions(options); + formatter.add(`${prefix}${cst('page')}.${fnc(method)}(${str(action.selector)}${optionsString})${suffix}`); + } + if (action.name === 'check') + formatter.add(`${prefix}${cst('page')}.${fnc('check')}(${str(action.selector)})${suffix}`); + if (action.name === 'uncheck') + formatter.add(`${prefix}${cst('page')}.${fnc('uncheck')}(${str(action.selector)})${suffix}`); + if (action.name === 'fill') + formatter.add(`${prefix}${cst('page')}.${fnc('fill')}(${str(action.selector)}, ${str(action.text)})${suffix}`); + if (action.name === 'press') + formatter.add(`${prefix}${cst('page')}.${fnc('press')}(${str(action.selector)}, ${str(action.key)})${suffix}`); + if (action.name === 'navigate') + formatter.add(`${prefix}${cst('page')}.${fnc('goto')}(${str(action.url)})${suffix}`); + if (action.name === 'select') + formatter.add(`${prefix}${cst('page')}.${fnc('select')}(${str(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})${suffix}`); + if (navigationSignal) + formatter.add(`]);`); + } + formatter.add(` + })(); + `); + return formatter.format(); + } +} + +function formatOptions(value: any): string { + const keys = Object.keys(value); + if (!keys.length) + return ''; + return ', ' + formatObject(value); +} + +function formatObject(value: any): string { + const { prp, str } = formatColors; + if (typeof value === 'string') + return str(value); + if (Array.isArray(value)) + return `[${value.map(o => formatObject(o)).join(', ')}]`; + if (typeof value === 'object') { + const keys = Object.keys(value); + if (!keys.length) + return '{}'; + const tokens: string[] = []; + for (const key of keys) + tokens.push(`${prp(key)}: ${formatObject(value[key])}`); + return `{ ${tokens.join(', ')} }`; + } + 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; +}