/** * 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 { Writable } from 'stream'; import { BrowserContextBase } from '../browserContext'; import * as types from '../types'; import { Events } from '../events'; import * as frames from '../frames'; import { Page } from '../page'; import * as actions from './recorderActions'; import { TerminalOutput } from './terminalOutput'; export class RecorderController { private _output: TerminalOutput; private _performingAction = false; private _pageAliases = new Map(); private _lastPopupOrdinal = 0; constructor(context: BrowserContextBase, output: Writable) { this._output = new TerminalOutput(output || process.stdout); context.on(Events.BrowserContext.Page, (page: Page) => { // First page is called page, others are called popup1, popup2, etc. const pageName = this._pageAliases.size ? 'popup' + ++this._lastPopupOrdinal : 'page'; this._pageAliases.set(page, pageName); page.on(Events.Page.Close, () => this._pageAliases.delete(page)); // Input actions that potentially lead to navigation are intercepted on the page and are // performed by the Playwright. page.exposeBinding('performPlaywrightAction', (source, action: actions.Action) => this._performAction(source.frame, action)).catch(e => {}); // Other non-essential actions are simply being recorded. page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => this._recordAction(source.frame, action)).catch(e => {}); page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => this._onFrameNavigated(frame)); page.on(Events.Page.Popup, (popup: Page) => this._onPopup(page, popup)); }); } private async _performAction(frame: frames.Frame, action: actions.Action) { this._performingAction = true; this._recordAction(frame, 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); if (action.name === 'select') await frame.selectOption(action.selector, action.options); this._performingAction = false; setTimeout(() => action.committed = true, 5000); } private async _recordAction(frame: frames.Frame, action: actions.Action) { this._output.addAction(this._pageAliases.get(frame._page)!, frame, action); } private _onFrameNavigated(frame: frames.Frame) { if (frame.parentFrame()) return; const pageAlias = this._pageAliases.get(frame._page); const action = this._output.lastAction(); // We only augment actions that have not been committed. if (action && !action.committed && action.name !== 'navigate') { // If we hit a navigation while action is executed, we assert it. Otherwise, we await it. this._output.signal(pageAlias!, frame, { name: 'navigation', url: frame.url(), type: this._performingAction ? 'assert' : 'await' }); } else if (!action || action.committed) { // If navigation happens out of the blue, we just log it. this._output.addAction( pageAlias!, frame, { name: 'navigate', url: frame.url(), signals: [], }); } } private _onPopup(page: Page, popup: Page) { const pageAlias = this._pageAliases.get(page)!; const popupAlias = this._pageAliases.get(popup)!; 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(pageAlias, page.mainFrame(), { name: 'popup', popupAlias }); } } } export function toClickOptions(action: actions.ClickAction): { method: 'click' | 'dblclick', options: types.MouseClickOptions } { let method: 'click' | 'dblclick' = 'click'; if (action.clickCount === 2) method = 'dblclick'; const modifiers = toModifiers(action.modifiers); const options: types.MouseClickOptions = {}; 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; }