chore(debug): various debug mode improvements (#2561)
This commit is contained in:
parent
2659910be8
commit
61b11252b4
|
|
@ -27,6 +27,7 @@ import { BrowserBase } from './browser';
|
||||||
import { InnerLogger, Logger } from './logger';
|
import { InnerLogger, Logger } from './logger';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { ProgressController } from './progress';
|
import { ProgressController } from './progress';
|
||||||
|
import { DebugController } from './debug/debugController';
|
||||||
|
|
||||||
type CommonContextOptions = {
|
type CommonContextOptions = {
|
||||||
viewport?: types.Size | null,
|
viewport?: types.Size | null,
|
||||||
|
|
@ -98,21 +99,8 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initialize() {
|
async _initialize() {
|
||||||
if (!helper.isDebugMode())
|
if (helper.isDebugMode())
|
||||||
return;
|
new DebugController(this);
|
||||||
|
|
||||||
const installInFrame = async (frame: frames.Frame) => {
|
|
||||||
try {
|
|
||||||
const mainContext = await frame._mainContext();
|
|
||||||
await mainContext.debugScript();
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.on(Events.BrowserContext.Page, (page: Page) => {
|
|
||||||
for (const frame of page.frames())
|
|
||||||
installInFrame(frame);
|
|
||||||
page.on(Events.Page.FrameNavigated, installInFrame);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise<any> {
|
async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise<any> {
|
||||||
|
|
|
||||||
43
src/debug/debugController.ts
Normal file
43
src/debug/debugController.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 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 { BrowserContextBase } from '../browserContext';
|
||||||
|
import { Events } from '../events';
|
||||||
|
import * as frames from '../frames';
|
||||||
|
import { Page } from '../page';
|
||||||
|
import { RecorderController } from './recorderController';
|
||||||
|
|
||||||
|
export class DebugController {
|
||||||
|
private _context: BrowserContextBase;
|
||||||
|
|
||||||
|
constructor(context: BrowserContextBase) {
|
||||||
|
this._context = context;
|
||||||
|
const installInFrame = async (frame: frames.Frame) => {
|
||||||
|
try {
|
||||||
|
const mainContext = await frame._mainContext();
|
||||||
|
await mainContext.debugScript();
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
context.on(Events.BrowserContext.Page, (page: Page) => {
|
||||||
|
for (const frame of page.frames())
|
||||||
|
installInFrame(frame);
|
||||||
|
page.on(Events.Page.FrameNavigated, installInFrame);
|
||||||
|
new RecorderController(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,6 @@ export default class DebugScript {
|
||||||
|
|
||||||
initialize(injectedScript: InjectedScript) {
|
initialize(injectedScript: InjectedScript) {
|
||||||
this.consoleAPI = new ConsoleAPI(injectedScript);
|
this.consoleAPI = new ConsoleAPI(injectedScript);
|
||||||
this.recorder = new Recorder();
|
this.recorder = new Recorder(injectedScript);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,24 +15,28 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as actions from '../recorderActions';
|
import type * as actions from '../recorderActions';
|
||||||
|
import InjectedScript from '../../injected/injectedScript';
|
||||||
|
import { parseSelector } from '../../common/selectorParser';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
recordPlaywrightAction?: (action: actions.Action) => void;
|
recordPlaywrightAction: (action: actions.Action) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Recorder {
|
export class Recorder {
|
||||||
constructor() {
|
private _injectedScript: InjectedScript;
|
||||||
|
|
||||||
|
constructor(injectedScript: InjectedScript) {
|
||||||
|
this._injectedScript = injectedScript;
|
||||||
|
|
||||||
document.addEventListener('click', event => this._onClick(event), true);
|
document.addEventListener('click', event => this._onClick(event), true);
|
||||||
document.addEventListener('input', event => this._onInput(event), true);
|
document.addEventListener('input', event => this._onInput(event), true);
|
||||||
document.addEventListener('keydown', event => this._onKeyDown(event), true);
|
document.addEventListener('keydown', event => this._onKeyDown(event), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onClick(event: MouseEvent) {
|
private _onClick(event: MouseEvent) {
|
||||||
if (!window.recordPlaywrightAction)
|
const selector = this._buildSelector(event.target as Element);
|
||||||
return;
|
|
||||||
const selector = this._buildSelector(event.target as Node);
|
|
||||||
if ((event.target as Element).nodeName === 'SELECT')
|
if ((event.target as Element).nodeName === 'SELECT')
|
||||||
return;
|
return;
|
||||||
window.recordPlaywrightAction({
|
window.recordPlaywrightAction({
|
||||||
|
|
@ -46,9 +50,7 @@ export class Recorder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onInput(event: Event) {
|
private _onInput(event: Event) {
|
||||||
if (!window.recordPlaywrightAction)
|
const selector = this._buildSelector(event.target as Element);
|
||||||
return;
|
|
||||||
const selector = this._buildSelector(event.target as Node);
|
|
||||||
if ((event.target as Element).nodeName === 'INPUT') {
|
if ((event.target as Element).nodeName === 'INPUT') {
|
||||||
const inputElement = event.target as HTMLInputElement;
|
const inputElement = event.target as HTMLInputElement;
|
||||||
if ((inputElement.type || '').toLowerCase() === 'checkbox') {
|
if ((inputElement.type || '').toLowerCase() === 'checkbox') {
|
||||||
|
|
@ -78,11 +80,9 @@ export class Recorder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onKeyDown(event: KeyboardEvent) {
|
private _onKeyDown(event: KeyboardEvent) {
|
||||||
if (!window.recordPlaywrightAction)
|
|
||||||
return;
|
|
||||||
if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape')
|
if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape')
|
||||||
return;
|
return;
|
||||||
const selector = this._buildSelector(event.target as Node);
|
const selector = this._buildSelector(event.target as Element);
|
||||||
window.recordPlaywrightAction({
|
window.recordPlaywrightAction({
|
||||||
name: 'press',
|
name: 'press',
|
||||||
selector,
|
selector,
|
||||||
|
|
@ -92,24 +92,73 @@ export class Recorder {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildSelector(node: Node): string {
|
private _buildSelector(targetElement: Element): string {
|
||||||
const element = node as Element;
|
const path: string[] = [];
|
||||||
for (const attribute of ['data-testid', 'aria-label', 'id', 'data-test-id', 'data-test']) {
|
const root = document.documentElement;
|
||||||
|
for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) {
|
||||||
|
const selector = this._buildSelectorCandidate(element);
|
||||||
|
if (selector)
|
||||||
|
path.unshift(selector.selector);
|
||||||
|
|
||||||
|
const fullSelector = path.join(' ');
|
||||||
|
if (selector && selector.final)
|
||||||
|
return fullSelector;
|
||||||
|
if (targetElement === this._injectedScript.querySelector(parseSelector(fullSelector), root))
|
||||||
|
return fullSelector;
|
||||||
|
}
|
||||||
|
return '<selector>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildSelectorCandidate(element: Element): { final: boolean, selector: string } | null {
|
||||||
|
for (const attribute of ['data-testid', 'data-test-id', 'data-test']) {
|
||||||
if (element.hasAttribute(attribute))
|
if (element.hasAttribute(attribute))
|
||||||
return `[${attribute}=${element.getAttribute(attribute)}]`;
|
return { final: true, selector: `${element.nodeName.toLocaleLowerCase()}[${attribute}=${element.getAttribute(attribute)}]` };
|
||||||
|
}
|
||||||
|
for (const attribute of ['aria-label']) {
|
||||||
|
if (element.hasAttribute(attribute))
|
||||||
|
return { final: false, selector: `${element.nodeName.toLocaleLowerCase()}[${attribute}=${element.getAttribute(attribute)}]` };
|
||||||
}
|
}
|
||||||
if (element.nodeName === 'INPUT') {
|
if (element.nodeName === 'INPUT') {
|
||||||
if (element.hasAttribute('name'))
|
if (element.hasAttribute('name'))
|
||||||
return `[input name=${element.getAttribute('name')}]`;
|
return { final: false, selector: `input[name=${element.getAttribute('name')}]` };
|
||||||
if (element.hasAttribute('type'))
|
if (element.hasAttribute('type'))
|
||||||
return `[input type=${element.getAttribute('type')}]`;
|
return { final: false, selector: `input[type=${element.getAttribute('type')}]` };
|
||||||
|
} else if (element.nodeName === 'IMG') {
|
||||||
|
if (element.hasAttribute('alt'))
|
||||||
|
return { final: false, selector: `img[alt="${element.getAttribute('alt')}"]` };
|
||||||
}
|
}
|
||||||
if (element.firstChild && element.firstChild === element.lastChild && element.firstChild.nodeType === Node.TEXT_NODE)
|
const textSelector = textSelectorForElement(element);
|
||||||
return `text="${element.textContent}"`;
|
if (textSelector)
|
||||||
return '<selector>';
|
return { final: false, selector: textSelector };
|
||||||
|
|
||||||
|
// Depreoritize id, but still use it as a last resort.
|
||||||
|
if (element.hasAttribute('id'))
|
||||||
|
return { final: true, selector: `${element.nodeName.toLocaleLowerCase()}[id=${element.getAttribute('id')}]` };
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textSelectorForElement(node: Node): string | null {
|
||||||
|
let needsTrim = false;
|
||||||
|
let onlyText: string | null = null;
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
if (child.nodeType !== Node.TEXT_NODE)
|
||||||
|
continue;
|
||||||
|
if (child.textContent && child.textContent.trim()) {
|
||||||
|
if (onlyText)
|
||||||
|
return null;
|
||||||
|
onlyText = child.textContent.trim();
|
||||||
|
needsTrim = child.textContent !== child.textContent.trim();
|
||||||
|
} else {
|
||||||
|
needsTrim = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!onlyText)
|
||||||
|
return null;
|
||||||
|
return needsTrim ? `text=/\\s*${escapeForRegex(onlyText)}\\s*/` : `text="${onlyText}"`;
|
||||||
|
}
|
||||||
|
|
||||||
function modifiersForEvent(event: MouseEvent | KeyboardEvent): number {
|
function modifiersForEvent(event: MouseEvent | KeyboardEvent): number {
|
||||||
return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
|
return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
|
||||||
}
|
}
|
||||||
|
|
@ -122,3 +171,7 @@ function buttonForEvent(event: MouseEvent): 'left' | 'middle' | 'right' {
|
||||||
}
|
}
|
||||||
return 'left';
|
return 'left';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeForRegex(text: string): string {
|
||||||
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,42 +18,34 @@ import * as actions from './recorderActions';
|
||||||
import * as frames from '../frames';
|
import * as frames from '../frames';
|
||||||
import { Page } from '../page';
|
import { Page } from '../page';
|
||||||
import { Events } from '../events';
|
import { Events } from '../events';
|
||||||
import { Script } from './recorderScript';
|
import { TerminalOutput } from './terminalOutput';
|
||||||
|
|
||||||
export class RecorderController {
|
export class RecorderController {
|
||||||
private _page: Page;
|
private _page: Page;
|
||||||
private _script = new Script();
|
private _output = new TerminalOutput();
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this._page = page;
|
this._page = page;
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
this._script.addAction({
|
|
||||||
name: 'navigate',
|
|
||||||
url: this._page.url(),
|
|
||||||
signals: [],
|
|
||||||
});
|
|
||||||
this._printScript();
|
|
||||||
|
|
||||||
this._page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => {
|
this._page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => {
|
||||||
action.frameUrl = source.frame.url();
|
if (source.frame !== this._page.mainFrame())
|
||||||
this._script.addAction(action);
|
action.frameUrl = source.frame.url();
|
||||||
this._printScript();
|
this._output.addAction(action);
|
||||||
});
|
});
|
||||||
|
|
||||||
this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => {
|
this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => {
|
||||||
if (frame.parentFrame())
|
if (frame.parentFrame())
|
||||||
return;
|
return;
|
||||||
const action = this._script.lastAction();
|
const action = this._output.lastAction();
|
||||||
if (action)
|
if (action) {
|
||||||
action.signals.push({ name: 'navigation', url: frame.url() });
|
this._output.signal({ name: 'navigation', url: frame.url() });
|
||||||
this._printScript();
|
} else {
|
||||||
|
this._output.addAction({
|
||||||
|
name: 'navigate',
|
||||||
|
url: this._page.url(),
|
||||||
|
signals: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_printScript() {
|
|
||||||
console.log('\x1Bc'); // eslint-disable-line no-console
|
|
||||||
console.log(this._script.generate('chromium')); // eslint-disable-line no-console
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 '../utils/formatter';
|
|
||||||
import { Action, NavigationSignal, actionTitle } from './recorderActions';
|
|
||||||
|
|
||||||
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 subject = action.frameUrl ?
|
|
||||||
`${cst('page')}.${fnc('frame')}(${formatObject({ url: action.frameUrl })})` : cst('page');
|
|
||||||
|
|
||||||
const prefix = navigationSignal ? '' : kwd('await') + ' ';
|
|
||||||
const suffix = navigationSignal ? '' : ';';
|
|
||||||
switch (action.name) {
|
|
||||||
case '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}${subject}.${fnc(method)}(${str(action.selector)}${optionsString})${suffix}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'check':
|
|
||||||
formatter.add(`${prefix}${subject}.${fnc('check')}(${str(action.selector)})${suffix}`);
|
|
||||||
break;
|
|
||||||
case 'uncheck':
|
|
||||||
formatter.add(`${prefix}${subject}.${fnc('uncheck')}(${str(action.selector)})${suffix}`);
|
|
||||||
break;
|
|
||||||
case 'fill':
|
|
||||||
formatter.add(`${prefix}${subject}.${fnc('fill')}(${str(action.selector)}, ${str(action.text)})${suffix}`);
|
|
||||||
break;
|
|
||||||
case 'press': {
|
|
||||||
const modifiers = toModifiers(action.modifiers);
|
|
||||||
const shortcut = [...modifiers, action.key].join('+');
|
|
||||||
formatter.add(`${prefix}${subject}.${fnc('press')}(${str(action.selector)}, ${str(shortcut)})${suffix}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'navigate':
|
|
||||||
formatter.add(`${prefix}${subject}.${fnc('goto')}(${str(action.url)})${suffix}`);
|
|
||||||
break;
|
|
||||||
case 'select':
|
|
||||||
formatter.add(`${prefix}${subject}.${fnc('select')}(${str(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})${suffix}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
183
src/debug/terminalOutput.ts
Normal file
183
src/debug/terminalOutput.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* 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 '../utils/formatter';
|
||||||
|
import { Action, NavigationSignal, actionTitle } from './recorderActions';
|
||||||
|
|
||||||
|
export class TerminalOutput {
|
||||||
|
private _lastAction: Action | undefined;
|
||||||
|
private _lastActionText: string | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const formatter = new Formatter();
|
||||||
|
const { cst, fnc, kwd, 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(`chromium`)}.${fnc('launch')}();
|
||||||
|
${kwd('const')} ${cst('page')} = ${kwd('await')} ${cst('browser')}.${fnc('newPage')}();
|
||||||
|
`);
|
||||||
|
process.stdout.write(formatter.format());
|
||||||
|
process.stdout.write(`\n})();`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addAction(action: Action) {
|
||||||
|
let eraseLastAction = false;
|
||||||
|
if (this._lastAction && action.name === 'fill' && this._lastAction.name === 'fill') {
|
||||||
|
if (action.selector === this._lastAction.selector)
|
||||||
|
eraseLastAction = true;
|
||||||
|
}
|
||||||
|
if (this._lastAction && action.name === 'click' && this._lastAction.name === 'click') {
|
||||||
|
if (action.selector === this._lastAction.selector && action.clickCount > this._lastAction.clickCount)
|
||||||
|
eraseLastAction = true;
|
||||||
|
}
|
||||||
|
for (const name of ['check', 'uncheck']) {
|
||||||
|
if (this._lastAction && action.name === name && this._lastAction.name === 'click') {
|
||||||
|
if ((action as any).selector === (this._lastAction as any).selector)
|
||||||
|
eraseLastAction = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._printAction(action, eraseLastAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
_printAction(action: Action, eraseLastAction: boolean) {
|
||||||
|
let eraseLines = 1;
|
||||||
|
if (eraseLastAction && this._lastActionText)
|
||||||
|
eraseLines += this._lastActionText.split('\n').length;
|
||||||
|
for (let i = 0; i < eraseLines; ++i)
|
||||||
|
process.stdout.write('\u001B[F\u001B[2K');
|
||||||
|
|
||||||
|
this._lastAction = action;
|
||||||
|
this._lastActionText = this._generateAction(action);
|
||||||
|
console.log(this._lastActionText); // eslint-disable-line no-console
|
||||||
|
console.log(`})();`); // eslint-disable-line no-console
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAction(): Action | undefined {
|
||||||
|
return this._lastAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
signal(signal: NavigationSignal) {
|
||||||
|
if (this._lastAction) {
|
||||||
|
this._lastAction.signals.push(signal);
|
||||||
|
this._printAction(this._lastAction, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
formatter.add(`${kwd('await')} ${cst('Promise')}.${fnc('all')}([
|
||||||
|
${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 ? '' : ';';
|
||||||
|
switch (action.name) {
|
||||||
|
case '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}${subject}.${fnc(method)}(${str(action.selector)}${optionsString})${suffix}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'check':
|
||||||
|
formatter.add(`${prefix}${subject}.${fnc('check')}(${str(action.selector)})${suffix}`);
|
||||||
|
break;
|
||||||
|
case 'uncheck':
|
||||||
|
formatter.add(`${prefix}${subject}.${fnc('uncheck')}(${str(action.selector)})${suffix}`);
|
||||||
|
break;
|
||||||
|
case 'fill':
|
||||||
|
formatter.add(`${prefix}${subject}.${fnc('fill')}(${str(action.selector)}, ${str(action.text)})${suffix}`);
|
||||||
|
break;
|
||||||
|
case 'press': {
|
||||||
|
const modifiers = toModifiers(action.modifiers);
|
||||||
|
const shortcut = [...modifiers, action.key].join('+');
|
||||||
|
formatter.add(`${prefix}${subject}.${fnc('press')}(${str(action.selector)}, ${str(shortcut)})${suffix}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'navigate':
|
||||||
|
formatter.add(`${prefix}${subject}.${fnc('goto')}(${str(action.url)})${suffix}`);
|
||||||
|
break;
|
||||||
|
case 'select':
|
||||||
|
formatter.add(`${prefix}${subject}.${fnc('select')}(${str(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})${suffix}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (navigationSignal)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,6 @@ import { EventEmitter } from 'events';
|
||||||
import { FileChooser } from './fileChooser';
|
import { FileChooser } from './fileChooser';
|
||||||
import { logError, InnerLogger } from './logger';
|
import { logError, InnerLogger } from './logger';
|
||||||
import { ProgressController } from './progress';
|
import { ProgressController } from './progress';
|
||||||
import { RecorderController } from './debug/recorderController';
|
|
||||||
|
|
||||||
export interface PageDelegate {
|
export interface PageDelegate {
|
||||||
readonly rawMouse: input.RawMouse;
|
readonly rawMouse: input.RawMouse;
|
||||||
|
|
@ -504,12 +503,6 @@ export class Page extends EventEmitter {
|
||||||
return this.mainFrame().uncheck(selector, options);
|
return this.mainFrame().uncheck(selector, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startRecordingUser() {
|
|
||||||
if (!helper.isDebugMode())
|
|
||||||
throw new Error('page._startRecordingUser is only available with PWDEBUG=1 environment variable');
|
|
||||||
new RecorderController(this).start();
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForTimeout(timeout: number) {
|
async waitForTimeout(timeout: number) {
|
||||||
await this.mainFrame().waitForTimeout(timeout);
|
await this.mainFrame().waitForTimeout(timeout);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ export interface Progress {
|
||||||
throwIfAborted(): void;
|
throwIfAborted(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let runningTaskCount = 0;
|
||||||
|
|
||||||
|
export function isRunningTask(): boolean {
|
||||||
|
return !!runningTaskCount;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, logger: InnerLogger, timeout: number, apiName?: string): Promise<T> {
|
export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, logger: InnerLogger, timeout: number, apiName?: string): Promise<T> {
|
||||||
const controller = new ProgressController(logger, timeout, apiName);
|
const controller = new ProgressController(logger, timeout, apiName);
|
||||||
return controller.run(task);
|
return controller.run(task);
|
||||||
|
|
@ -70,6 +76,7 @@ export class ProgressController {
|
||||||
async run<T>(task: (progress: Progress) => Promise<T>): Promise<T> {
|
async run<T>(task: (progress: Progress) => Promise<T>): Promise<T> {
|
||||||
assert(this._state === 'before');
|
assert(this._state === 'before');
|
||||||
this._state = 'running';
|
this._state = 'running';
|
||||||
|
++runningTaskCount;
|
||||||
|
|
||||||
const progress: Progress = {
|
const progress: Progress = {
|
||||||
apiName: this._apiName,
|
apiName: this._apiName,
|
||||||
|
|
@ -104,7 +111,6 @@ export class ProgressController {
|
||||||
const result = await Promise.race([promise, this._forceAbortPromise]);
|
const result = await Promise.race([promise, this._forceAbortPromise]);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
this._state = 'finished';
|
this._state = 'finished';
|
||||||
this._logRecording = [];
|
|
||||||
this._logger.log(apiLog, `<= ${this._apiName} succeeded`);
|
this._logger.log(apiLog, `<= ${this._apiName} succeeded`);
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -112,10 +118,12 @@ export class ProgressController {
|
||||||
rewriteErrorMessage(e, e.message + formatLogRecording(this._logRecording, this._apiName) + kLoggingNote);
|
rewriteErrorMessage(e, e.message + formatLogRecording(this._logRecording, this._apiName) + kLoggingNote);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
this._state = 'aborted';
|
this._state = 'aborted';
|
||||||
this._logRecording = [];
|
|
||||||
this._logger.log(apiLog, `<= ${this._apiName} failed`);
|
this._logger.log(apiLog, `<= ${this._apiName} failed`);
|
||||||
await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
|
await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
|
||||||
throw e;
|
throw e;
|
||||||
|
} finally {
|
||||||
|
this._logRecording = [];
|
||||||
|
--runningTaskCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@
|
||||||
|
|
||||||
export class Formatter {
|
export class Formatter {
|
||||||
private _baseIndent: string;
|
private _baseIndent: string;
|
||||||
|
private _baseOffset: string;
|
||||||
private _lines: string[] = [];
|
private _lines: string[] = [];
|
||||||
|
|
||||||
constructor(indent: number = 2) {
|
constructor(offset = 0) {
|
||||||
this._baseIndent = ' '.repeat(indent);
|
this._baseIndent = ' '.repeat(2);
|
||||||
|
this._baseOffset = ' '.repeat(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
prepend(text: string) {
|
prepend(text: string) {
|
||||||
|
|
@ -49,7 +51,7 @@ export class Formatter {
|
||||||
line = spaces + extraSpaces + line;
|
line = spaces + extraSpaces + line;
|
||||||
if (line.endsWith('{') || line.endsWith('['))
|
if (line.endsWith('{') || line.endsWith('['))
|
||||||
spaces += this._baseIndent;
|
spaces += this._baseIndent;
|
||||||
return line;
|
return this._baseOffset + line;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +64,7 @@ export const formatColors: { cst: StringFormatter; kwd: StringFormatter; fnc: St
|
||||||
fnc: text => `\u001b[38;5;223m${text}\x1b[0m`,
|
fnc: text => `\u001b[38;5;223m${text}\x1b[0m`,
|
||||||
prp: text => `\u001b[38;5;159m${text}\x1b[0m`,
|
prp: text => `\u001b[38;5;159m${text}\x1b[0m`,
|
||||||
str: text => `\u001b[38;5;130m${quote(text)}\x1b[0m`,
|
str: text => `\u001b[38;5;130m${quote(text)}\x1b[0m`,
|
||||||
cmt: text => `// \u001b[38;5;23m${text}\x1b[0m`
|
cmt: text => `\u001b[38;5;23m// ${text}\x1b[0m`
|
||||||
};
|
};
|
||||||
|
|
||||||
function quote(text: string, char: string = '\'') {
|
function quote(text: string, char: string = '\'') {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue