Skip to content

Commit

Permalink
feat(debug): add basic recording helper infra (#2533)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Jun 11, 2020
1 parent 5e97acd commit e287f19
Show file tree
Hide file tree
Showing 5 changed files with 487 additions and 0 deletions.
5 changes: 5 additions & 0 deletions src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
106 changes: 106 additions & 0 deletions src/recorder/actions.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
76 changes: 76 additions & 0 deletions src/recorder/formatter.ts
Original file line number Diff line number Diff line change
@@ -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');
}
149 changes: 149 additions & 0 deletions src/recorder/recorder.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit e287f19

Please sign in to comment.