feat(cli): bring in codegen and tests (#4815)

This commit is contained in:
Dmitry Gozman 2020-12-28 14:50:12 -08:00 committed by GitHub
parent 4c11f5d885
commit 293a7bdd4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 3523 additions and 12 deletions

5
package-lock.json generated
View File

@ -4042,6 +4042,11 @@
"minimalistic-assert": "^1.0.1"
}
},
"highlight.js": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz",
"integrity": "sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg=="
},
"hmac-drbg": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",

View File

@ -44,6 +44,7 @@
"commander": "^6.1.0",
"debug": "^4.1.1",
"extract-zip": "^2.0.1",
"highlight.js": "^10.1.2",
"https-proxy-agent": "^5.0.0",
"jpeg-js": "^0.4.2",
"mime": "^2.4.6",

View File

@ -54,8 +54,9 @@ function run_tests {
test_playwright_electron_should_work
test_electron_types
test_android_types
test_playwright_cli_should_work
test_playwright_cli_screenshot_should_work
test_playwright_cli_install_should_work
test_playwright_cli_codegen_should_work
}
function test_screencast {
@ -332,7 +333,7 @@ function test_android_types {
echo "${FUNCNAME[0]} success"
}
function test_playwright_cli_should_work {
function test_playwright_cli_screenshot_should_work {
initialize_test "${FUNCNAME[0]}"
npm install ${PLAYWRIGHT_TGZ}
@ -375,6 +376,36 @@ function test_playwright_cli_install_should_work {
echo "${FUNCNAME[0]} success"
}
function test_playwright_cli_codegen_should_work {
initialize_test "${FUNCNAME[0]}"
npm install ${PLAYWRIGHT_TGZ}
echo "Running playwright codegen"
OUTPUT=$(PWCLI_EXIT_FOR_TEST=1 xvfb-run --auto-servernum -- bash -c "npx playwright codegen")
if [[ "${OUTPUT}" != *"chromium.launch"* ]]; then
echo "ERROR: missing chromium.launch in the output"
exit 1
fi
if [[ "${OUTPUT}" != *"browser.close"* ]]; then
echo "ERROR: missing browser.close in the output"
exit 1
fi
echo "Running playwright codegen --target=python"
OUTPUT=$(PWCLI_EXIT_FOR_TEST=1 xvfb-run --auto-servernum -- bash -c "npx playwright codegen --target=python")
if [[ "${OUTPUT}" != *"chromium.launch"* ]]; then
echo "ERROR: missing chromium.launch in the output"
exit 1
fi
if [[ "${OUTPUT}" != *"browser.close"* ]]; then
echo "ERROR: missing browser.close in the output"
exit 1
fi
echo "${FUNCNAME[0]} success"
}
function initialize_test {
cd ${TEST_ROOT}
local TEST_NAME="./$1"

View File

@ -24,13 +24,14 @@ import * as os from 'os';
import * as fs from 'fs';
import { installBrowsersWithProgressBar } from '../install/installer';
import * as consoleApiSource from '../generated/consoleApiSource';
// TODO: we can import from '../..' instead, but that requires generating types
// before build, and currently type generator depends on the build.
import type { Browser, BrowserContext, Page, BrowserType } from '../client/api';
import type { Playwright } from '../client/playwright';
import type { BrowserContextOptions, LaunchOptions } from '../client/types';
const playwright = require('../inprocess') as Playwright;
import { OutputMultiplexer, TerminalOutput, FileOutput } from './codegen/outputs';
import { CodeGenerator, CodeGeneratorOutput } from './codegen/codeGenerator';
import { JavaScriptLanguageGenerator, LanguageGenerator } from './codegen/languages';
import { PythonLanguageGenerator } from './codegen/languages/python';
import { CSharpLanguageGenerator } from './codegen/languages/csharp';
import { RecorderController } from './codegen/recorderController';
import type { Browser, BrowserContext, Page, BrowserType, BrowserContextOptions, LaunchOptions } from '../..';
import * as playwright from '../..';
program
.version('Version ' + require('../../package.json').version)
@ -80,6 +81,22 @@ for (const {alias, name, type} of browsers) {
});
}
program
.command('codegen [url]')
.description('open page and generate code for user actions')
.option('-o, --output <file name>', 'saves the generated script to a file')
.option('--target <language>', `language to use, one of javascript, python, python-async, csharp`, process.env.PW_CLI_TARGET_LANG || 'javascript')
.action(function(url, command) {
codegen(command.parent, url, command.target, command.output);
}).on('--help', function() {
console.log('');
console.log('Examples:');
console.log('');
console.log(' $ codegen');
console.log(' $ codegen --target=python');
console.log(' $ -b webkit codegen https://example.com');
});
program
.command('screenshot <url> <filename>')
.description('capture a page screenshot')
@ -285,7 +302,36 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi
async function open(options: Options, url: string | undefined) {
const { context } = await launchContext(options, false);
context._extendInjectedScript(consoleApiSource.source);
(context as any)._extendInjectedScript(consoleApiSource.source);
await openPage(context, url);
if (process.env.PWCLI_EXIT_FOR_TEST)
await Promise.all(context.pages().map(p => p.close()));
}
async function codegen(options: Options, url: string | undefined, target: string, outputFile?: string) {
let languageGenerator: LanguageGenerator;
switch (target) {
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break;
case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break;
case 'python':
case 'python-async': languageGenerator = new PythonLanguageGenerator(target === 'python-async'); break;
default: throw new Error(`Invalid target: '${target}'`);
}
const { context, browserName, launchOptions, contextOptions } = await launchContext(options, false);
if (process.env.PWTRACE)
contextOptions.recordVideo = { dir: path.join(process.cwd(), '.trace') };
const outputs: CodeGeneratorOutput[] = [new TerminalOutput(process.stdout, languageGenerator.highligherType())];
if (outputFile)
outputs.push(new FileOutput(outputFile));
const output = new OutputMultiplexer(outputs);
const generator = new CodeGenerator(browserName, launchOptions, contextOptions, output, languageGenerator, options.device, options.saveStorage);
new RecorderController(context, generator);
(context as any)._extendInjectedScript(consoleApiSource.source);
await openPage(context, url);
if (process.env.PWCLI_EXIT_FOR_TEST)
await Promise.all(context.pages().map(p => p.close()));
@ -326,7 +372,7 @@ async function pdf(options: Options, captureOptions: CaptureOptions, url: string
await browser.close();
}
function lookupBrowserType(options: Options): BrowserType {
function lookupBrowserType(options: Options): BrowserType<Browser> {
let name = options.browser;
if (options.device) {
const device = playwright.devices[options.device];

View File

@ -0,0 +1,137 @@
/**
* 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 type { LaunchOptions, Frame, BrowserContextOptions } from '../../..';
import { LanguageGenerator } from './languages';
import { Action, Signal } from './recorderActions';
export type ActionInContext = {
pageAlias: string;
frame: Frame;
action: Action;
committed?: boolean;
}
export interface CodeGeneratorOutput {
printLn(text: string): void;
popLn(text: string): void;
flush(): void;
}
export class CodeGenerator {
private _currentAction: ActionInContext | undefined;
private _lastAction: ActionInContext | undefined;
private _lastActionText: string | undefined;
private _languageGenerator: LanguageGenerator;
private _output: CodeGeneratorOutput;
private _footerText: string;
constructor(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, deviceName: string | undefined, saveStorage: string | undefined) {
this._output = output;
this._languageGenerator = languageGenerator;
launchOptions = { headless: false, ...launchOptions };
const header = this._languageGenerator.generateHeader(browserName, launchOptions, contextOptions, deviceName);
this._output.printLn(header);
this._footerText = '\n' + this._languageGenerator.generateFooter(saveStorage);
this._output.printLn(this._footerText);
}
exit() {
this._output.flush();
}
addAction(action: ActionInContext) {
this.willPerformAction(action);
this.didPerformAction(action);
}
willPerformAction(action: ActionInContext) {
this._currentAction = action;
}
didPerformAction(actionInContext: ActionInContext) {
const { action, pageAlias } = actionInContext;
let eraseLastAction = false;
if (this._lastAction && this._lastAction.pageAlias === pageAlias) {
const { action: lastAction } = this._lastAction;
// We augment last action based on the type.
if (this._lastAction && action.name === 'fill' && lastAction.name === 'fill') {
if (action.selector === lastAction.selector)
eraseLastAction = true;
}
if (lastAction && action.name === 'click' && lastAction.name === 'click') {
if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount)
eraseLastAction = true;
}
if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') {
if (action.url === lastAction.url)
return;
}
for (const name of ['check', 'uncheck']) {
if (lastAction && action.name === name && lastAction.name === 'click') {
if ((action as any).selector === (lastAction as any).selector)
eraseLastAction = true;
}
}
}
this._printAction(actionInContext, eraseLastAction);
}
commitLastAction() {
const action = this._lastAction;
if (action)
action.committed = true;
}
_printAction(actionInContext: ActionInContext, eraseLastAction: boolean) {
this._output.popLn(this._footerText);
if (eraseLastAction && this._lastActionText)
this._output.popLn(this._lastActionText);
const performingAction = !!this._currentAction;
this._currentAction = undefined;
this._lastAction = actionInContext;
this._lastActionText = this._languageGenerator.generateAction(actionInContext, performingAction);
this._output.printLn(this._lastActionText);
this._output.printLn(this._footerText);
}
signal(pageAlias: string, frame: Frame, signal: Signal) {
// Signal either arrives while action is being performed or shortly after.
if (this._currentAction) {
this._currentAction.action.signals.push(signal);
return;
}
if (this._lastAction && !this._lastAction.committed) {
this._lastAction.action.signals.push(signal);
this._printAction(this._lastAction, true);
return;
}
if (signal.name === 'navigation') {
this.addAction({
pageAlias,
frame,
committed: true,
action: {
name: 'navigate',
url: frame.url(),
signals: [],
}
});
}
}
}

View File

@ -0,0 +1,314 @@
/**
* 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 type { BrowserContextOptions, LaunchOptions } from '../../../..';
import * as playwright from '../../../..';
import { HighlighterType, LanguageGenerator } from '.';
import { ActionInContext } from '../codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from '../recorderActions';
import { MouseClickOptions, toModifiers } from '../utils';
export class CSharpLanguageGenerator implements LanguageGenerator {
highligherType(): HighlighterType {
return 'csharp';
}
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
const { action, pageAlias, frame } = actionInContext;
const formatter = new CSharpFormatter(0);
formatter.newLine();
formatter.add('// ' + actionTitle(action));
if (action.name === 'openPage') {
formatter.add(`var ${pageAlias} = await context.NewPageAsync();`);
if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/')
formatter.add(`${pageAlias}.GoToAsync('${action.url}');`);
return formatter.format();
}
const subject = !frame.parentFrame() ? pageAlias :
`${pageAlias}.GetFrame(url: '${frame.url()}')`;
let navigationSignal: NavigationSignal | undefined;
let popupSignal: PopupSignal | undefined;
let downloadSignal: DownloadSignal | undefined;
let dialogSignal: DialogSignal | undefined;
for (const signal of action.signals) {
if (signal.name === 'navigation')
navigationSignal = signal;
else if (signal.name === 'popup')
popupSignal = signal;
else if (signal.name === 'download')
downloadSignal = signal;
else if (signal.name === 'dialog')
dialogSignal = signal;
}
if (dialogSignal) {
formatter.add(` void ${pageAlias}_Dialog${dialogSignal.dialogAlias}_EventHandler(object sender, DialogEventArgs e)
{
Console.WriteLine($"Dialog message: {e.Dialog.Message}");
e.Dialog.DismissAsync();
${pageAlias}.Dialog -= ${pageAlias}_Dialog${dialogSignal.dialogAlias}_EventHandler;
}
${pageAlias}.Dialog += ${pageAlias}_Dialog${dialogSignal.dialogAlias}_EventHandler;`);
}
const waitForNavigation = navigationSignal && !performingAction;
const assertNavigation = navigationSignal && performingAction;
const emitTaskWhenAll = waitForNavigation || popupSignal || downloadSignal;
if (emitTaskWhenAll) {
if (popupSignal)
formatter.add(`var ${popupSignal.popupAlias}Task = ${pageAlias}.WaitForEventAsync(PageEvent.Popup)`);
else if (downloadSignal)
formatter.add(`var downloadTask = ${pageAlias}.WaitForEventAsync(PageEvent.Download);`);
formatter.add(`await Task.WhenAll(`);
}
// Popup signals.
if (popupSignal)
formatter.add(`${popupSignal.popupAlias}Task,`);
// Navigation signal.
if (waitForNavigation)
formatter.add(`${pageAlias}.WaitForNavigationAsync(/*${quote(navigationSignal!.url)}*/),`);
// Download signals.
if (downloadSignal)
formatter.add(`downloadTask,`);
const prefix = (popupSignal || waitForNavigation || downloadSignal) ? '' : 'await ';
const actionCall = this._generateActionCall(action);
const suffix = emitTaskWhenAll ? ');' : ';';
formatter.add(`${prefix}${subject}.${actionCall}${suffix}`);
if (assertNavigation)
formatter.add(` // Assert.Equal(${quote(navigationSignal!.url)}, ${pageAlias}.Url);`);
return formatter.format();
}
private _generateActionCall(action: Action): string {
switch (action.name) {
case 'openPage':
throw Error('Not reached');
case 'closePage':
return 'CloseAsync()';
case 'click': {
let method = 'ClickAsync';
if (action.clickCount === 2)
method = 'DblClickAsync';
const modifiers = toModifiers(action.modifiers);
const options: MouseClickOptions = {};
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, true, false);
return `${method}(${quote(action.selector)}${optionsString})`;
}
case 'check':
return `CheckAsync(${quote(action.selector)})`;
case 'uncheck':
return `UncheckAsync(${quote(action.selector)})`;
case 'fill':
return `FillAsync(${quote(action.selector)}, ${quote(action.text)})`;
case 'setInputFiles':
return `SetInputFilesAsync(${quote(action.selector)}, ${formatObject(action.files.length === 1 ? action.files[0] : action.files)})`;
case 'press': {
const modifiers = toModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
return `PressAsync(${quote(action.selector)}, ${quote(shortcut)})`;
}
case 'navigate':
return `GoToAsync(${quote(action.url)})`;
case 'select':
return `SelectOptionAsync(${quote(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})`;
}
}
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string {
const formatter = new CSharpFormatter(0);
formatter.add(`
await Playwright.InstallAsync();
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.${toPascal(browserName)}.LaunchAsync(${formatArgs(launchOptions)});
var context = await browser.NewContextAsync(${formatContextOptions(contextOptions, deviceName)});`);
return formatter.format();
}
generateFooter(saveStorage: string | undefined): string {
const storageStateLine = saveStorage ? `\nawait context.StorageStateAsync(path: "${saveStorage}")` : '';
return `// ---------------------${storageStateLine}`;
}
}
function formatValue(value: any): string {
if (value === false)
return 'false';
if (value === true)
return 'true';
if (value === undefined)
return 'null';
if (Array.isArray(value))
return `new [] {${value.map(formatValue).join(', ')}}`;
if (typeof value === 'string')
return quote(value);
return String(value);
}
function formatOptions(value: any, hasArguments: boolean, isInitializing: boolean): string {
const keys = Object.keys(value);
if (!keys.length)
return '';
return (hasArguments ? ', ' : '') + keys.map(key => `${key}${isInitializing ? ': ' : ' = '}${formatValue(value[key])}`).join(', ');
}
function formatArgs(value: any, indent = ' '): string {
if (typeof value === 'string')
return quote(value);
if (Array.isArray(value))
return `new [] {${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(`${keys.length !== 1 ? indent : ''}${key}: ${formatObject(value[key], indent, key)}`);
if (keys.length === 1)
return `${tokens.join(`,\n${indent}`)}`;
else
return `\n${indent}${tokens.join(`,\n${indent}`)}`;
}
return String(value);
}
function formatObject(value: any, indent = ' ', name = ''): string {
if (typeof value === 'string') {
if (name === 'permissions' || name === 'colorScheme')
return `${getClassName(name)}.${toPascal(value)}`;
return quote(value);
}
if (Array.isArray(value))
return `new[] { ${value.map(o => formatObject(o, indent, name)).join(', ')} }`;
if (typeof value === 'object') {
const keys = Object.keys(value);
if (!keys.length)
return '';
const tokens: string[] = [];
for (const key of keys)
tokens.push(`${toPascal(key)} = ${formatObject(value[key], indent, key)},`);
if (name)
return `new ${getClassName(name)}\n{\n${indent}${tokens.join(`\n${indent}`)}\n${indent}}`;
return `{\n${indent}${tokens.join(`\n${indent}`)}\n${indent}}`;
}
if (name === 'latitude' || name === 'longitude')
return String(value) + 'm';
return String(value);
}
function getClassName(value: string): string {
switch (value) {
case 'viewport': return 'ViewportSize';
case 'proxy': return 'ProxySettings';
case 'permissions': return 'ContextPermission';
default: return toPascal(value);
}
}
function toPascal(value: string): string {
return value[0].toUpperCase() + value.slice(1);
}
function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string {
const device = deviceName && playwright.devices[deviceName];
if (!device)
return formatArgs(options);
// Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {};
for (const property in options) {
if ((device as any)[property] !== (options as any)[property])
cleanedOptions[property] = (options as any)[property];
}
const serializedObject = formatObject(cleanedOptions, ' ');
// When there are no additional context options, we still want to spread the device inside.
if (!serializedObject)
return `playwright.Devices["${deviceName}"]`;
let result = `new BrowserContextOptions(playwright.Devices["${deviceName}"])`;
if (serializedObject) {
const lines = serializedObject.split('\n');
result = `${result} \n${lines.join('\n')}`;
}
return result;
}
class CSharpFormatter {
private _baseIndent: string;
private _baseOffset: string;
private _lines: string[] = [];
constructor(offset = 0) {
this._baseIndent = ' '.repeat(4);
this._baseOffset = ' '.repeat(offset);
}
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(']') || line.includes('});'))
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('[') || line.endsWith('('))
spaces += this._baseIndent;
if (line.endsWith('});'))
spaces = spaces.substring(this._baseIndent.length);
return this._baseOffset + line;
}).join('\n');
}
}
function quote(text: string) {
return `"${text.replace(/["]/g, '\\"')}"`;
}

View File

@ -0,0 +1,29 @@
/**
* 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 type { BrowserContextOptions, LaunchOptions } from '../../../..';
import { ActionInContext } from '../codeGenerator';
export type HighlighterType = 'javascript' | 'csharp' | 'python';
export interface LanguageGenerator {
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string;
generateAction(actionInContext: ActionInContext, performingAction: boolean): string;
generateFooter(saveStorage: string | undefined): string;
highligherType(): HighlighterType;
}
export { JavaScriptLanguageGenerator } from './javascript';

View File

@ -0,0 +1,266 @@
/**
* 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 type { BrowserContextOptions, LaunchOptions } from '../../../..';
import * as playwright from '../../../..';
import { HighlighterType, LanguageGenerator } from '.';
import { ActionInContext } from '../codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from '../recorderActions';
import { MouseClickOptions, toModifiers } from '../utils';
export class JavaScriptLanguageGenerator implements LanguageGenerator {
highligherType(): HighlighterType {
return 'javascript';
}
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
const { action, pageAlias, frame } = actionInContext;
const formatter = new JavaScriptFormatter(2);
formatter.newLine();
formatter.add('// ' + actionTitle(action));
if (action.name === 'openPage') {
formatter.add(`const ${pageAlias} = await context.newPage();`);
if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/')
formatter.add(`${pageAlias}.goto('${action.url}');`);
return formatter.format();
}
const subject = !frame.parentFrame() ? pageAlias :
`${pageAlias}.frame(${formatObject({ url: frame.url() })})`;
let navigationSignal: NavigationSignal | undefined;
let popupSignal: PopupSignal | undefined;
let downloadSignal: DownloadSignal | undefined;
let dialogSignal: DialogSignal | undefined;
for (const signal of action.signals) {
if (signal.name === 'navigation')
navigationSignal = signal;
else if (signal.name === 'popup')
popupSignal = signal;
else if (signal.name === 'download')
downloadSignal = signal;
else if (signal.name === 'dialog')
dialogSignal = signal;
}
if (dialogSignal) {
formatter.add(` ${pageAlias}.once('dialog', dialog => {
console.log(\`Dialog message: $\{dialog.message()}\`);
dialog.dismiss().catch(() => {});
});`);
}
const waitForNavigation = navigationSignal && !performingAction;
const assertNavigation = navigationSignal && performingAction;
const emitPromiseAll = waitForNavigation || popupSignal || downloadSignal;
if (emitPromiseAll) {
// Generate either await Promise.all([]) or
// const [popup1] = await Promise.all([]).
let leftHandSide = '';
if (popupSignal)
leftHandSide = `const [${popupSignal.popupAlias}] = `;
else if (downloadSignal)
leftHandSide = `const [download] = `;
formatter.add(`${leftHandSide}await Promise.all([`);
}
// Popup signals.
if (popupSignal)
formatter.add(`${pageAlias}.waitForEvent('popup'),`);
// Navigation signal.
if (waitForNavigation)
formatter.add(`${pageAlias}.waitForNavigation(/*{ url: ${quote(navigationSignal!.url)} }*/),`);
// Download signals.
if (downloadSignal)
formatter.add(`${pageAlias}.waitForEvent('download'),`);
const prefix = (popupSignal || waitForNavigation || downloadSignal) ? '' : 'await ';
const actionCall = this._generateActionCall(action);
const suffix = (waitForNavigation || emitPromiseAll) ? '' : ';';
formatter.add(`${prefix}${subject}.${actionCall}${suffix}`);
if (emitPromiseAll)
formatter.add(`]);`);
else if (assertNavigation)
formatter.add(` // assert.equal(${pageAlias}.url(), ${quote(navigationSignal!.url)});`);
return formatter.format();
}
private _generateActionCall(action: Action): string {
switch (action.name) {
case 'openPage':
throw Error('Not reached');
case 'closePage':
return 'close()';
case 'click': {
let method = 'click';
if (action.clickCount === 2)
method = 'dblclick';
const modifiers = toModifiers(action.modifiers);
const options: MouseClickOptions = {};
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);
return `${method}(${quote(action.selector)}${optionsString})`;
}
case 'check':
return `check(${quote(action.selector)})`;
case 'uncheck':
return `uncheck(${quote(action.selector)})`;
case 'fill':
return `fill(${quote(action.selector)}, ${quote(action.text)})`;
case 'setInputFiles':
return `setInputFiles(${quote(action.selector)}, ${formatObject(action.files.length === 1 ? action.files[0] : action.files)})`;
case 'press': {
const modifiers = toModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
return `press(${quote(action.selector)}, ${quote(shortcut)})`;
}
case 'navigate':
return `goto(${quote(action.url)})`;
case 'select':
return `selectOption(${quote(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})`;
}
}
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string {
const formatter = new JavaScriptFormatter();
formatter.add(`
const { ${browserName}${deviceName ? ', devices' : ''} } = require('playwright');
(async () => {
const browser = await ${browserName}.launch(${formatObjectOrVoid(launchOptions)});
const context = await browser.newContext(${formatContextOptions(contextOptions, deviceName)});`);
return formatter.format();
}
generateFooter(saveStorage: string | undefined): string {
const storageStateLine = saveStorage ? `\n await context.storageState({ path: '${saveStorage}' })` : '';
return ` // ---------------------${storageStateLine}
await context.close();
await browser.close();
})();`;
}
}
function formatOptions(value: any): string {
const keys = Object.keys(value);
if (!keys.length)
return '';
return ', ' + formatObject(value);
}
function formatObject(value: any, indent = ' '): string {
if (typeof value === 'string')
return quote(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(`${key}: ${formatObject(value[key])}`);
return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
}
return String(value);
}
function formatObjectOrVoid(value: any, indent = ' '): string {
const result = formatObject(value, indent);
return result === '{}' ? '' : result;
}
function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string {
const device = deviceName && playwright.devices[deviceName];
if (!device)
return formatObjectOrVoid(options);
// Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {};
for (const property in options) {
if ((device as any)[property] !== (options as any)[property])
cleanedOptions[property] = (options as any)[property];
}
let serializedObject = formatObjectOrVoid(cleanedOptions);
// When there are no additional context options, we still want to spread the device inside.
if (!serializedObject)
serializedObject = '{\n}';
const lines = serializedObject.split('\n');
lines.splice(1, 0, `...devices['${deviceName}'],`);
return lines.join('\n');
}
class JavaScriptFormatter {
private _baseIndent: string;
private _baseOffset: string;
private _lines: string[] = [];
constructor(offset = 0) {
this._baseIndent = ' '.repeat(2);
this._baseOffset = ' '.repeat(offset);
}
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 this._baseOffset + line;
}).join('\n');
}
}
function quote(text: string, char: string = '\'') {
if (char === '\'')
return char + text.replace(/[']/g, '\\\'') + char;
if (char === '"')
return char + text.replace(/["]/g, '\\"') + char;
if (char === '`')
return char + text.replace(/[`]/g, '\\`') + char;
throw new Error('Invalid escape char');
}

View File

@ -0,0 +1,279 @@
/**
* 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 type { BrowserContextOptions, LaunchOptions } from '../../../..';
import * as playwright from '../../../..';
import { HighlighterType, LanguageGenerator } from '.';
import { ActionInContext } from '../codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from '../recorderActions';
import { MouseClickOptions, toModifiers } from '../utils';
export class PythonLanguageGenerator implements LanguageGenerator {
private _awaitPrefix: '' | 'await ';
private _asyncPrefix: '' | 'async ';
private _isAsync: boolean;
constructor(isAsync: boolean) {
this._isAsync = isAsync;
this._awaitPrefix = isAsync ? 'await ' : '';
this._asyncPrefix = isAsync ? 'async ' : '';
}
highligherType(): HighlighterType {
return 'python';
}
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
const { action, pageAlias, frame } = actionInContext;
const formatter = new PythonFormatter(4);
formatter.newLine();
formatter.add('# ' + actionTitle(action));
if (action.name === 'openPage') {
formatter.add(`${pageAlias} = ${this._awaitPrefix}context.newPage()`);
if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/')
formatter.add(`${pageAlias}.goto('${action.url}')`);
return formatter.format();
}
const subject = !frame.parentFrame() ? pageAlias :
`${pageAlias}.frame(${formatOptions({ url: frame.url() }, false)})`;
let navigationSignal: NavigationSignal | undefined;
let popupSignal: PopupSignal | undefined;
let downloadSignal: DownloadSignal | undefined;
let dialogSignal: DialogSignal | undefined;
for (const signal of action.signals) {
if (signal.name === 'navigation')
navigationSignal = signal;
else if (signal.name === 'popup')
popupSignal = signal;
else if (signal.name === 'download')
downloadSignal = signal;
else if (signal.name === 'dialog')
dialogSignal = signal;
}
if (dialogSignal)
formatter.add(` ${pageAlias}.once("dialog", lambda dialog: asyncio.create_task(dialog.dismiss()))`);
const waitForNavigation = navigationSignal && !performingAction;
const assertNavigation = navigationSignal && performingAction;
const actionCall = this._generateActionCall(action);
let code = `${this._awaitPrefix}${subject}.${actionCall}`;
if (popupSignal) {
code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as popup_info {
${code}
}
${popupSignal.popupAlias} = popup_info.value`;
}
if (downloadSignal) {
code = `${this._asyncPrefix}with ${pageAlias}.expect_download() as download_info {
${code}
}
download = download_info.value`;
}
if (waitForNavigation) {
code = `
# ${this._asyncPrefix}with ${pageAlias}.expect_navigation(url=${quote(navigationSignal!.url)}):
${this._asyncPrefix}with ${pageAlias}.expect_navigation() {
${code}
}`;
}
formatter.add(code);
if (assertNavigation)
formatter.add(` # assert ${pageAlias}.url == ${quote(navigationSignal!.url)}`);
return formatter.format();
}
private _generateActionCall(action: Action): string {
switch (action.name) {
case 'openPage':
throw Error('Not reached');
case 'closePage':
return 'close()';
case 'click': {
let method = 'click';
if (action.clickCount === 2)
method = 'dblclick';
const modifiers = toModifiers(action.modifiers);
const options: MouseClickOptions = {};
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, true);
return `${method}(${quote(action.selector)}${optionsString})`;
}
case 'check':
return `check(${quote(action.selector)})`;
case 'uncheck':
return `uncheck(${quote(action.selector)})`;
case 'fill':
return `fill(${quote(action.selector)}, ${quote(action.text)})`;
case 'setInputFiles':
return `setInputFiles(${quote(action.selector)}, ${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`;
case 'press': {
const modifiers = toModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
return `press(${quote(action.selector)}, ${quote(shortcut)})`;
}
case 'navigate':
return `goto(${quote(action.url)})`;
case 'select':
return `selectOption(${quote(action.selector)}, ${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`;
}
}
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string {
const formatter = new PythonFormatter();
if (this._isAsync) {
formatter.add(`
import asyncio
from playwright import async_playwright
async def run(playwright) {
browser = await playwright.${browserName}.launch(${formatOptions(launchOptions, false)})
context = await browser.newContext(${formatContextOptions(contextOptions, deviceName)})`);
} else {
formatter.add(`
from playwright import sync_playwright
def run(playwright) {
browser = playwright.${browserName}.launch(${formatOptions(launchOptions, false)})
context = browser.newContext(${formatContextOptions(contextOptions, deviceName)})`);
}
return formatter.format();
}
generateFooter(saveStorage: string | undefined): string {
if (this._isAsync) {
const storageStateLine = saveStorage ? `\n await context.storageState(path="${saveStorage}")` : '';
return ` # ---------------------${storageStateLine}
await context.close()
await browser.close()
async def main():
async with async_playwright() as playwright:
await run(playwright)
asyncio.run(main())`;
} else {
const storageStateLine = saveStorage ? `\n context.storageState(path="${saveStorage}")` : '';
return ` # ---------------------${storageStateLine}
context.close()
browser.close()
with sync_playwright() as playwright:
run(playwright)`;
}
}
}
function formatValue(value: any): string {
if (value === false)
return 'False';
if (value === true)
return 'True';
if (value === undefined)
return 'None';
if (Array.isArray(value))
return `[${value.map(formatValue).join(', ')}]`;
if (typeof value === 'string')
return quote(value);
return String(value);
}
function formatOptions(value: any, hasArguments: boolean): string {
const keys = Object.keys(value);
if (!keys.length)
return '';
return (hasArguments ? ', ' : '') + keys.map(key => `${key}=${formatValue(value[key])}`).join(', ');
}
function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string {
const device = deviceName && playwright.devices[deviceName];
if (!device)
return formatOptions(options, false);
// Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {};
for (const property in options) {
if ((device as any)[property] !== (options as any)[property])
cleanedOptions[property] = (options as any)[property];
}
return `**playwright.devices["${deviceName}"]` + formatOptions(cleanedOptions, true);
}
class PythonFormatter {
private _baseIndent: string;
private _baseOffset: string;
private _lines: string[] = [];
constructor(offset = 0) {
this._baseIndent = ' '.repeat(4);
this._baseOffset = ' '.repeat(offset);
}
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 = '';
const lines: string[] = [];
this._lines.forEach((line: string) => {
if (line === '')
return lines.push(line);
if (line === '}') {
spaces = spaces.substring(this._baseIndent.length);
return;
}
line = spaces + line;
if (line.endsWith('{')) {
spaces += this._baseIndent;
line = line.substring(0, line.length - 1).trimEnd() + ':';
}
return lines.push(this._baseOffset + line);
});
return lines.join('\n');
}
}
function quote(text: string, char: string = '\"') {
if (char === '\'')
return char + text.replace(/[']/g, '\\\'') + char;
if (char === '"')
return char + text.replace(/["]/g, '\\"') + char;
if (char === '`')
return char + text.replace(/[`]/g, '\\`') + char;
throw new Error('Invalid escape char');
}

114
src/cli/codegen/outputs.ts Normal file
View File

@ -0,0 +1,114 @@
/**
* 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 fs from 'fs';
import * as querystring from 'querystring';
import { Writable } from 'stream';
import { highlight } from 'highlight.js';
import { CodeGeneratorOutput } from './codeGenerator';
export class OutputMultiplexer implements CodeGeneratorOutput {
private _outputs: CodeGeneratorOutput[]
constructor(outputs: CodeGeneratorOutput[]) {
this._outputs = outputs;
}
printLn(text: string) {
for (const output of this._outputs)
output.printLn(text);
}
popLn(text: string) {
for (const output of this._outputs)
output.popLn(text);
}
flush() {
for (const output of this._outputs)
output.flush();
}
}
export class FileOutput implements CodeGeneratorOutput {
private _fileName: string;
private _lines: string[];
constructor(fileName: string) {
this._fileName = fileName;
this._lines = [];
}
printLn(text: string) {
this._lines.push(...text.trimEnd().split('\n'));
}
popLn(text: string) {
this._lines.length -= text.trimEnd().split('\n').length;
}
flush() {
fs.writeFileSync(this._fileName, this._lines.join('\n'));
}
}
export class TerminalOutput implements CodeGeneratorOutput {
private _output: Writable
private _language: string;
constructor(output: Writable, language: string) {
this._output = output;
this._language = language;
}
private _highlight(text: string) {
let highlightedCode = highlight(this._language, text).value;
highlightedCode = querystring.unescape(highlightedCode);
highlightedCode = highlightedCode.replace(/<span class="hljs-keyword">/g, '\x1b[38;5;205m');
highlightedCode = highlightedCode.replace(/<span class="hljs-built_in">/g, '\x1b[38;5;220m');
highlightedCode = highlightedCode.replace(/<span class="hljs-literal">/g, '\x1b[38;5;159m');
highlightedCode = highlightedCode.replace(/<span class="hljs-title">/g, '');
highlightedCode = highlightedCode.replace(/<span class="hljs-number">/g, '\x1b[38;5;78m');
highlightedCode = highlightedCode.replace(/<span class="hljs-string">/g, '\x1b[38;5;130m');
highlightedCode = highlightedCode.replace(/<span class="hljs-comment">/g, '\x1b[38;5;23m');
highlightedCode = highlightedCode.replace(/<span class="hljs-subst">/g, '\x1b[38;5;242m');
highlightedCode = highlightedCode.replace(/<span class="hljs-function">/g, '');
highlightedCode = highlightedCode.replace(/<span class="hljs-params">/g, '');
highlightedCode = highlightedCode.replace(/<span class="hljs-attr">/g, '');
highlightedCode = highlightedCode.replace(/<\/span>/g, '\x1b[0m');
highlightedCode = highlightedCode.replace(/&#x27;/g, "'");
highlightedCode = highlightedCode.replace(/&quot;/g, '"');
highlightedCode = highlightedCode.replace(/&gt;/g, '>');
highlightedCode = highlightedCode.replace(/&lt;/g, '<');
highlightedCode = highlightedCode.replace(/&amp;/g, '&');
return highlightedCode;
}
printLn(text: string) {
// Split into lines for highlighter to not fail.
for (const line of text.split('\n'))
this._output.write(this._highlight(line) + '\n');
}
popLn(text: string) {
const terminalWidth = process.stdout.columns || 80;
for (const line of text.split('\n')) {
const terminalLines = ((line.length - 1) / terminalWidth | 0) + 1;
for (let i = 0; i < terminalLines; ++i)
this._output.write('\u001B[1A\u001B[2K');
}
}
flush() {}
}

View File

@ -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.
*/
export type ActionName =
'check' |
'click' |
'closePage' |
'fill' |
'navigate' |
'openPage' |
'press' |
'select' |
'uncheck' |
'setInputFiles';
export type ActionBase = {
name: ActionName,
signals: Signal[],
}
export type ClickAction = ActionBase & {
name: 'click',
selector: string,
button: 'left' | 'middle' | 'right',
modifiers: number,
clickCount: number,
};
export type CheckAction = ActionBase & {
name: 'check',
selector: string,
};
export type UncheckAction = ActionBase & {
name: 'uncheck',
selector: string,
};
export type FillAction = ActionBase & {
name: 'fill',
selector: string,
text: string,
};
export type NavigateAction = ActionBase & {
name: 'navigate',
url: string,
};
export type OpenPageAction = ActionBase & {
name: 'openPage',
url: string,
};
export type ClosesPageAction = ActionBase & {
name: 'closePage',
};
export type PressAction = ActionBase & {
name: 'press',
selector: string,
key: string,
modifiers: number,
};
export type SelectAction = ActionBase & {
name: 'select',
selector: string,
options: string[],
};
export type SetInputFilesAction = ActionBase & {
name: 'setInputFiles',
selector: string,
files: string[],
};
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction;
// Signals.
export type NavigationSignal = {
name: 'navigation',
url: string,
};
export type PopupSignal = {
name: 'popup',
popupAlias: string,
};
export type DownloadSignal = {
name: 'download',
};
export type DialogSignal = {
name: 'dialog',
dialogAlias: string,
};
export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal;
export function actionTitle(action: Action): string {
switch (action.name) {
case 'openPage':
return `Open new page`;
case 'closePage':
return `Close page`;
case 'check':
return `Check ${action.selector}`;
case 'uncheck':
return `Uncheck ${action.selector}`;
case 'click': {
if (action.clickCount === 1)
return `Click ${action.selector}`;
if (action.clickCount === 2)
return `Double click ${action.selector}`;
if (action.clickCount === 3)
return `Triple click ${action.selector}`;
return `${action.clickCount}× click`;
}
case 'fill':
return `Fill ${action.selector}`;
case 'setInputFiles':
if (action.files.length === 0)
return `Clear selected files`;
else
return `Upload ${action.files.join(', ')}`;
case 'navigate':
return `Go to ${action.url}`;
case 'press':
return `Press ${action.key}` + (action.modifiers ? ' with modifiers' : '');
case 'select':
return `Select ${action.options.join(', ')}`;
}
}

View File

@ -0,0 +1,164 @@
/**
* 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 type { Page, BrowserContext, Frame, Download, Dialog } from '../../..';
import * as actions from './recorderActions';
import { CodeGenerator, ActionInContext } from './codeGenerator';
import { toClickOptions, toModifiers } from './utils';
import * as recorderSource from '../../generated/recorderSource';
type BindingSource = { frame: Frame, page: Page };
export class RecorderController {
private _generator: CodeGenerator;
private _pageAliases = new Map<Page, string>();
private _lastPopupOrdinal = 0;
private _lastDialogOrdinal = 0;
private _timers = new Set<NodeJS.Timeout>();
constructor(context: BrowserContext, generator: CodeGenerator) {
(context as any)._extendInjectedScript(recorderSource.source);
this._generator = generator;
// Input actions that potentially lead to navigation are intercepted on the page and are
// performed by the Playwright.
context.exposeBinding('performPlaywrightAction',
(source: BindingSource, action: actions.Action) => this._performAction(source.frame, action)).catch(e => {});
// Other non-essential actions are simply being recorded.
context.exposeBinding('recordPlaywrightAction',
(source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action)).catch(e => {});
// Commits last action so that no further signals are added to it.
context.exposeBinding('commitLastAction',
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction()).catch(e => {});
context.on('page', page => this._onPage(page));
for (const page of context.pages())
this._onPage(page);
context.once('close', () => {
for (const timer of this._timers)
clearTimeout(timer);
this._timers.clear();
this._generator.exit();
});
}
private async _onPage(page: Page) {
// First page is called page, others are called popup1, popup2, etc.
page.on('close', () => {
this._pageAliases.delete(page);
this._generator.addAction({
pageAlias,
frame: page.mainFrame(),
committed: true,
action: {
name: 'closePage',
signals: [],
}
});
});
page.on('framenavigated', frame => this._onFrameNavigated(frame, page));
page.on('download', download => this._onDownload(page, download));
page.on('popup', popup => this._onPopup(page, popup));
page.on('dialog', dialog => this._onDialog(page, dialog));
const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : '';
const pageAlias = 'page' + suffix;
this._pageAliases.set(page, pageAlias);
const isPopup = !!await page.opener();
// Could happen due to the await above.
if (page.isClosed())
return;
if (!isPopup) {
this._generator.addAction({
pageAlias,
frame: page.mainFrame(),
committed: true,
action: {
name: 'openPage',
url: page.url(),
signals: [],
}
});
}
}
private async _performAction(frame: Frame, action: actions.Action) {
const page = frame.page();
const actionInContext: ActionInContext = {
pageAlias: this._pageAliases.get(page)!,
frame,
action
};
this._generator.willPerformAction(actionInContext);
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);
const timer = setTimeout(() => {
actionInContext.committed = true;
this._timers.delete(timer);
}, 5000);
this._generator.didPerformAction(actionInContext);
this._timers.add(timer);
}
private async _recordAction(frame: Frame, action: actions.Action) {
// We are lacking frame.page() in
this._generator.addAction({
pageAlias: this._pageAliases.get(frame.page())!,
frame,
action
});
}
private _onFrameNavigated(frame: Frame, page: Page) {
if (frame.parentFrame())
return;
const pageAlias = this._pageAliases.get(page);
this._generator.signal(pageAlias!, frame, { name: 'navigation', url: frame.url() });
}
private _onPopup(page: Page, popup: Page) {
const pageAlias = this._pageAliases.get(page)!;
const popupAlias = this._pageAliases.get(popup)!;
this._generator.signal(pageAlias, page.mainFrame(), { name: 'popup', popupAlias });
}
private _onDownload(page: Page, download: Download) {
const pageAlias = this._pageAliases.get(page)!;
this._generator.signal(pageAlias, page.mainFrame(), { name: 'download' });
}
private _onDialog(page: Page, dialog: Dialog) {
const pageAlias = this._pageAliases.get(page)!;
this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) });
}
}

48
src/cli/codegen/utils.ts Normal file
View File

@ -0,0 +1,48 @@
/**
* 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 type { Page } from '../../..';
import * as actions from './recorderActions';
export type MouseClickOptions = Parameters<Page['click']>[1];
export function toClickOptions(action: actions.ClickAction): { method: 'click' | 'dblclick', options: MouseClickOptions } {
let method: 'click' | 'dblclick' = 'click';
if (action.clickCount === 2)
method = 'dblclick';
const modifiers = toModifiers(action.modifiers);
const options: 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;
}

View File

@ -0,0 +1,514 @@
/**
* 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 type * as actions from '../codegen/recorderActions';
import type InjectedScript from '../../server/injected/injectedScript';
import { generateSelector } from '../../debug/injected/selectorGenerator';
import { html } from './html';
declare global {
interface Window {
performPlaywrightAction: (action: actions.Action) => Promise<void>;
recordPlaywrightAction: (action: actions.Action) => Promise<void>;
commitLastAction: () => Promise<void>;
}
}
const scriptSymbol = Symbol('scriptSymbol');
export class Recorder {
private _injectedScript: InjectedScript;
private _performingAction = false;
private _outerGlassPaneElement: HTMLElement;
private _glassPaneShadow: ShadowRoot;
private _innerGlassPaneElement: HTMLElement;
private _highlightElements: HTMLElement[] = [];
private _tooltipElement: HTMLElement;
private _listeners: RegisteredListener[] = [];
private _hoveredModel: HighlightModel | null = null;
private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null;
private _expectProgrammaticKeyUp = false;
constructor(injectedScript: InjectedScript) {
this._injectedScript = injectedScript;
this._outerGlassPaneElement = html`
<x-pw-glass style="
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2147483647;
pointer-events: none;
display: flex;
">
</x-pw-glass>`;
this._tooltipElement = html`<x-pw-tooltip></x-pw-tooltip>`;
this._innerGlassPaneElement = html`
<x-pw-glass-inner style="flex: auto">
${this._tooltipElement}
</x-pw-glass-inner>`;
// Use a closed shadow root to prevent selectors matching our internal previews.
this._glassPaneShadow = this._outerGlassPaneElement.attachShadow({ mode: 'closed' });
this._glassPaneShadow.appendChild(this._innerGlassPaneElement);
this._glassPaneShadow.appendChild(html`
<style>
x-pw-tooltip {
align-items: center;
backdrop-filter: blur(5px);
background-color: rgba(0, 0, 0, 0.7);
border-radius: 2px;
box-shadow: rgba(0, 0, 0, 0.1) 0px 3.6px 3.7px,
rgba(0, 0, 0, 0.15) 0px 12.1px 12.3px,
rgba(0, 0, 0, 0.1) 0px -2px 4px,
rgba(0, 0, 0, 0.15) 0px -12.1px 24px,
rgba(0, 0, 0, 0.25) 0px 54px 55px;
color: rgb(204, 204, 204);
display: none;
font-family: 'Dank Mono', 'Operator Mono', Inconsolata, 'Fira Mono',
'SF Mono', Monaco, 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 12.8px;
font-weight: normal;
left: 0;
line-height: 1.5;
max-width: 600px;
padding: 3.2px 5.12px 3.2px;
position: absolute;
top: 0;
}
</style>
`);
setInterval(() => {
this._refreshListenersIfNeeded();
}, 100);
}
private _refreshListenersIfNeeded() {
if ((document.documentElement as any)[scriptSymbol])
return;
(document.documentElement as any)[scriptSymbol] = true;
removeEventListeners(this._listeners);
this._listeners = [
addEventListener(document, 'click', event => this._onClick(event as MouseEvent), true),
addEventListener(document, 'input', event => this._onInput(event), true),
addEventListener(document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true),
addEventListener(document, 'keyup', event => this._onKeyUp(event as KeyboardEvent), true),
addEventListener(document, 'mousedown', event => this._onMouseDown(event as MouseEvent), true),
addEventListener(document, 'mouseup', event => this._onMouseUp(event as MouseEvent), true),
addEventListener(document, 'mousemove', event => this._onMouseMove(event as MouseEvent), true),
addEventListener(document, 'mouseleave', event => this._onMouseLeave(event as MouseEvent), true),
addEventListener(document, 'focus', () => this._onFocus(), true),
addEventListener(document, 'scroll', () => {
this._hoveredModel = null;
this._updateHighlight();
}, true),
];
document.documentElement.appendChild(this._outerGlassPaneElement);
if ((window as any)._recorderScriptReadyForTest)
(window as any)._recorderScriptReadyForTest();
}
private _actionInProgress(event: Event): boolean {
// If Playwright is performing action for us, bail.
if (this._performingAction)
return true;
// Consume as the first thing.
consumeEvent(event);
return false;
}
private _consumedDueToNoModel(event: Event, model: HighlightModel | null): boolean {
if (model)
return false;
consumeEvent(event);
return true;
}
private _consumedDueWrongTarget(event: Event): boolean {
if (this._activeModel && this._activeModel.elements[0] === deepEventTarget(event))
return false;
consumeEvent(event);
return true;
}
private _onClick(event: MouseEvent) {
if (this._shouldIgnoreMouseEvent(event))
return;
if (this._actionInProgress(event))
return;
if (this._consumedDueToNoModel(event, this._hoveredModel))
return;
const checkbox = asCheckbox(deepEventTarget(event));
if (checkbox) {
// Interestingly, inputElement.checked is reversed inside this event handler.
this._performAction({
name: checkbox.checked ? 'check' : 'uncheck',
selector: this._hoveredModel!.selector,
signals: [],
});
return;
}
this._performAction({
name: 'click',
selector: this._hoveredModel!.selector,
signals: [],
button: buttonForEvent(event),
modifiers: modifiersForEvent(event),
clickCount: event.detail
});
}
private _shouldIgnoreMouseEvent(event: MouseEvent): boolean {
const target = deepEventTarget(event);
const nodeName = target.nodeName;
if (nodeName === 'SELECT')
return true;
if (nodeName === 'INPUT' && ['date'].includes((target as HTMLInputElement).type))
return true;
return false;
}
private _onMouseDown(event: MouseEvent) {
if (this._shouldIgnoreMouseEvent(event))
return;
if (!this._performingAction)
consumeEvent(event);
this._activeModel = this._hoveredModel;
}
private _onMouseUp(event: MouseEvent) {
if (this._shouldIgnoreMouseEvent(event))
return;
if (!this._performingAction)
consumeEvent(event);
}
private _onMouseMove(event: MouseEvent) {
const target = deepEventTarget(event);
if (this._hoveredElement === target)
return;
this._hoveredElement = target;
// Mouse moved -> mark last action as committed via committing a commit action.
this._commitActionAndUpdateModelForHoveredElement();
}
private _onMouseLeave(event: MouseEvent) {
// Leaving iframe.
if (deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
this._hoveredElement = null;
this._commitActionAndUpdateModelForHoveredElement();
}
}
private _onFocus() {
const activeElement = deepActiveElement(document);
const result = activeElement ? generateSelector(this._injectedScript, activeElement) : null;
this._activeModel = result && result.selector ? result : null;
if ((window as any)._highlightUpdatedForTest)
(window as any)._highlightUpdatedForTest(result ? result.selector : null);
}
private _commitActionAndUpdateModelForHoveredElement() {
if (!this._hoveredElement) {
this._hoveredModel = null;
this._updateHighlight();
return;
}
const hoveredElement = this._hoveredElement;
const { selector, elements } = generateSelector(this._injectedScript, hoveredElement);
if ((this._hoveredModel && this._hoveredModel.selector === selector) || this._hoveredElement !== hoveredElement)
return;
window.commitLastAction();
this._hoveredModel = selector ? { selector, elements } : null;
this._updateHighlight();
if ((window as any)._highlightUpdatedForTest)
(window as any)._highlightUpdatedForTest(selector);
}
private _updateHighlight() {
const elements = this._hoveredModel ? this._hoveredModel.elements : [];
// Code below should trigger one layout and leave with the
// destroyed layout.
// Destroy the layout
this._tooltipElement.textContent = this._hoveredModel ? this._hoveredModel.selector : '';
this._tooltipElement.style.top = '0';
this._tooltipElement.style.left = '0';
this._tooltipElement.style.display = 'flex';
// Trigger layout.
const boxes = elements.map(e => e.getBoundingClientRect());
const tooltipWidth = this._tooltipElement.offsetWidth;
const tooltipHeight = this._tooltipElement.offsetHeight;
const totalWidth = this._innerGlassPaneElement.offsetWidth;
const totalHeight = this._innerGlassPaneElement.offsetHeight;
// Destroy the layout again.
if (boxes.length) {
const primaryBox = boxes[0];
let anchorLeft = primaryBox.left;
if (anchorLeft + tooltipWidth > totalWidth - 5)
anchorLeft = totalWidth - tooltipWidth - 5;
let anchorTop = primaryBox.bottom + 5;
if (anchorTop + tooltipHeight > totalHeight - 5) {
// If can't fit below, either position above...
if (primaryBox.top > tooltipHeight + 5) {
anchorTop = primaryBox.top - tooltipHeight - 5;
} else {
// Or on top in case of large element
anchorTop = totalHeight - 5 - tooltipHeight;
}
}
this._tooltipElement.style.top = anchorTop + 'px';
this._tooltipElement.style.left = anchorLeft + 'px';
} else {
this._tooltipElement.style.display = 'none';
}
const pool = this._highlightElements;
this._highlightElements = [];
for (const box of boxes) {
const highlightElement = pool.length ? pool.shift()! : this._createHighlightElement();
highlightElement.style.borderColor = this._highlightElements.length ? 'hotpink' : '#8929ff';
highlightElement.style.left = box.x + 'px';
highlightElement.style.top = box.y + 'px';
highlightElement.style.width = box.width + 'px';
highlightElement.style.height = box.height + 'px';
highlightElement.style.display = 'block';
this._highlightElements.push(highlightElement);
}
for (const highlightElement of pool) {
highlightElement.style.display = 'none';
this._highlightElements.push(highlightElement);
}
}
private _createHighlightElement(): HTMLElement {
const highlightElement = html`
<x-pw-highlight style="
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
border: 1px solid;
box-sizing: border-box;">
</x-pw-highlight>`;
this._glassPaneShadow.appendChild(highlightElement);
return highlightElement;
}
private _onInput(event: Event) {
const target = deepEventTarget(event);
if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
const inputElement = target as HTMLInputElement;
const elementType = (inputElement.type || '').toLowerCase();
if (elementType === 'checkbox') {
// Checkbox is handled in click, we can't let input trigger on checkbox - that would mean we dispatched click events while recording.
return;
}
if (elementType === 'file') {
window.recordPlaywrightAction({
name: 'setInputFiles',
selector: this._activeModel!.selector,
signals: [],
files: [...(inputElement.files || [])].map(file => file.name),
});
return;
}
// Non-navigating actions are simply recorded by Playwright.
if (this._consumedDueWrongTarget(event))
return;
window.recordPlaywrightAction({
name: 'fill',
selector: this._activeModel!.selector,
signals: [],
text: inputElement.value,
});
}
if (target.nodeName === 'SELECT') {
const selectElement = target as HTMLSelectElement;
if (this._actionInProgress(event))
return;
this._performAction({
name: 'select',
selector: this._hoveredModel!.selector,
options: [...selectElement.selectedOptions].map(option => option.value),
signals: []
});
}
}
private _shouldGenerateKeyPressFor(event: KeyboardEvent): boolean {
// Backspace, Delete are changing input, will handle it there.
if (['Backspace', 'Delete'].includes(event.key))
return false;
// Ignore the QWERTZ shortcut for creating a at sign on MacOS
if (event.key === '@' && event.code === 'KeyL')
return false;
// Allow and ignore common used shortcut for pasting.
if (process.platform === 'darwin') {
if (event.key === 'v' && event.metaKey)
return false;
} else {
if (event.key === 'v' && event.ctrlKey)
return false;
if (event.key === 'Insert' && event.shiftKey)
return false;
}
if (['Shift', 'Control', 'Meta', 'Alt'].includes(event.key))
return false;
const hasModifier = event.ctrlKey || event.altKey || event.metaKey;
if (event.key.length === 1 && !hasModifier)
return !!asCheckbox(deepEventTarget(event));
return true;
}
private _onKeyDown(event: KeyboardEvent) {
if (!this._shouldGenerateKeyPressFor(event))
return;
if (this._actionInProgress(event)) {
this._expectProgrammaticKeyUp = true;
return;
}
if (this._consumedDueWrongTarget(event))
return;
// Similarly to click, trigger checkbox on key event, not input.
if (event.key === ' ') {
const checkbox = asCheckbox(deepEventTarget(event));
if (checkbox) {
this._performAction({
name: checkbox.checked ? 'uncheck' : 'check',
selector: this._activeModel!.selector,
signals: [],
});
return;
}
}
this._performAction({
name: 'press',
selector: this._activeModel!.selector,
signals: [],
key: event.key,
modifiers: modifiersForEvent(event),
});
}
private _onKeyUp(event: KeyboardEvent) {
if (!this._shouldGenerateKeyPressFor(event))
return;
// Only allow programmatic keyups, ignore user input.
if (!this._expectProgrammaticKeyUp) {
consumeEvent(event);
return;
}
this._expectProgrammaticKeyUp = false;
}
private async _performAction(action: actions.Action) {
this._performingAction = true;
await window.performPlaywrightAction(action);
this._performingAction = false;
// Action could have changed DOM, update hovered model selectors.
this._commitActionAndUpdateModelForHoveredElement();
// If that was a keyboard action, it similarly requires new selectors for active model.
this._onFocus();
if ((window as any)._actionPerformedForTest) {
(window as any)._actionPerformedForTest({
hovered: this._hoveredModel ? this._hoveredModel.selector : null,
active: this._activeModel ? this._activeModel.selector : null,
});
}
}
}
function deepEventTarget(event: Event): HTMLElement {
return event.composedPath()[0] as HTMLElement;
}
function deepActiveElement(document: Document): Element | null {
let activeElement = document.activeElement;
while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement)
activeElement = activeElement.shadowRoot.activeElement;
return activeElement;
}
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';
}
function consumeEvent(e: Event) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
type HighlightModel = {
selector: string;
elements: Element[];
};
function asCheckbox(node: Node | null): HTMLInputElement | null {
if (!node || node.nodeName !== 'INPUT')
return null;
const inputElement = node as HTMLInputElement;
return inputElement.type === 'checkbox' ? inputElement : null;
}
type RegisteredListener = {
target: EventTarget;
eventName: string;
listener: EventListener;
useCapture?: boolean;
};
function addEventListener(target: EventTarget, eventName: string, listener: EventListener, useCapture?: boolean): RegisteredListener {
target.addEventListener(eventName, listener, useCapture);
return { target, eventName, listener, useCapture };
}
function removeEventListeners(listeners: RegisteredListener[]) {
for (const listener of listeners)
listener.target.removeEventListener(listener.eventName, listener.listener, listener.useCapture);
listeners.splice(0, listeners.length);
}
export default Recorder;

View File

@ -0,0 +1,46 @@
/**
* 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.
*/
const path = require('path');
const InlineSource = require('../../server/injected/webpack-inline-source-plugin');
module.exports = {
entry: path.join(__dirname, 'recorder.ts'),
devtool: 'source-map',
module: {
rules: [
{
test: /\.(j|t)sx?$/,
loader: 'ts-loader',
options: {
transpileOnly: true
},
exclude: /node_modules/
}
]
},
resolve: {
extensions: [ '.tsx', '.ts', '.js' ]
},
output: {
libraryTarget: 'var',
filename: 'recorderSource.js',
path: path.resolve(__dirname, '../../../lib/server/injected/packed')
},
plugins: [
new InlineSource(path.join(__dirname, '..', '..', 'generated', 'recorderSource.ts')),
]
};

View File

@ -0,0 +1 @@
contents of the file

View File

@ -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 path from 'path';
import * as fs from 'fs';
import { folio } from './cli.fixtures';
const { it, expect } = folio;
const emptyHTML = new URL('file://' + path.join(__dirname, '..', 'assets', 'empty.html')).toString();
it('should print the correct imports and context options', async ({ runCLI }) => {
const cli = runCLI(['codegen', '--target=csharp', emptyHTML]);
const expectedResult = `await Playwright.InstallAsync();
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(headless: false);
var context = await browser.NewContextAsync();`;
await cli.waitFor(expectedResult).catch(e => e);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options for custom settings', async ({ runCLI }) => {
const cli = runCLI([
'--color-scheme=dark',
'--geolocation=37.819722,-122.478611',
'--lang=es',
'--proxy-server=http://myproxy:3128',
'--timezone=Europe/Rome',
'--timeout=1000',
'--user-agent=hardkodemium',
'--viewport-size=1280,720',
'codegen',
'--target=csharp',
emptyHTML]);
const expectedResult = `await Playwright.InstallAsync();
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(
headless: false,
proxy: new ProxySettings
{
Server = "http://myproxy:3128",
});
var context = await browser.NewContextAsync(
viewport: new ViewportSize
{
Width = 1280,
Height = 720,
},
geolocation: new Geolocation
{
Latitude = 37.819722m,
Longitude = -122.478611m,
},
permissions: new[] { ContextPermission.Geolocation },
userAgent: "hardkodemium",
locale: "es",
colorScheme: ColorScheme.Dark,
timezoneId: "Europe/Rome");`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options when using a device', async ({ runCLI }) => {
const cli = runCLI(['--device=Pixel 2', 'codegen', '--target=csharp', emptyHTML]);
const expectedResult = `await Playwright.InstallAsync();
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(headless: false);
var context = await browser.NewContextAsync(playwright.Devices["Pixel 2"]);`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options when using a device and additional options', async ({ runCLI }) => {
const cli = runCLI([
'--device=Pixel 2',
'--color-scheme=dark',
'--geolocation=37.819722,-122.478611',
'--lang=es',
'--proxy-server=http://myproxy:3128',
'--timezone=Europe/Rome',
'--timeout=1000',
'--user-agent=hardkodemium',
'--viewport-size=1280,720',
'codegen',
'--target=csharp',
emptyHTML]);
const expectedResult = `await Playwright.InstallAsync();
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(
headless: false,
proxy: new ProxySettings
{
Server = "http://myproxy:3128",
});
var context = await browser.NewContextAsync(new BrowserContextOptions(playwright.Devices["Pixel 2"])
{
UserAgent = "hardkodemium",
Viewport = new ViewportSize
{
Width = 1280,
Height = 720,
},
Geolocation = new Geolocation
{
Latitude = 37.819722m,
Longitude = -122.478611m,
},
Permissions = new[] { ContextPermission.Geolocation },
Locale = "es",
ColorScheme = ColorScheme.Dark,
TimezoneId = "Europe/Rome",
});`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print load/save storageState', async ({ runCLI, testInfo }) => {
const loadFileName = testInfo.outputPath('load.json');
const saveFileName = testInfo.outputPath('save.json');
await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8');
const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, 'codegen', '--target=csharp', emptyHTML]);
const expectedResult = `await Playwright.InstallAsync();
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var context = await browser.NewContextAsync(storageState: "${loadFileName}");
// Open new page
var page = await context.NewPageAsync();
// ---------------------
await context.StorageStateAsync(path: "${saveFileName}");
`;
await cli.waitFor(expectedResult);
});

View File

@ -0,0 +1,134 @@
/**
* 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 fs from 'fs';
import * as path from 'path';
import { folio } from './cli.fixtures';
const { it, expect } = folio;
const emptyHTML = new URL('file://' + path.join(__dirname, '..', 'assets', 'empty.html')).toString();
it('should print the correct imports and context options', async ({ runCLI }) => {
const cli = runCLI(['codegen', emptyHTML]);
const expectedResult = `const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({
headless: false
});
const context = await browser.newContext();`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options for custom settings', async ({ runCLI }) => {
const cli = runCLI(['--color-scheme=light', 'codegen', emptyHTML]);
const expectedResult = `const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({
headless: false
});
const context = await browser.newContext({
colorScheme: 'light'
});`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options when using a device', async ({ runCLI }) => {
const cli = runCLI(['--device=Pixel 2', 'codegen', emptyHTML]);
const expectedResult = `const { chromium, devices } = require('playwright');
(async () => {
const browser = await chromium.launch({
headless: false
});
const context = await browser.newContext({
...devices['Pixel 2'],
});`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options when using a device and additional options', async ({ runCLI }) => {
const cli = runCLI(['--color-scheme=light', '--device=Pixel 2', 'codegen', emptyHTML]);
const expectedResult = `const { chromium, devices } = require('playwright');
(async () => {
const browser = await chromium.launch({
headless: false
});
const context = await browser.newContext({
...devices['Pixel 2'],
colorScheme: 'light'
});`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should save the codegen output to a file if specified', async ({ runCLI, testInfo }) => {
const tmpFile = testInfo.outputPath('script.js');
const cli = runCLI(['codegen', '--output', tmpFile, emptyHTML]);
await cli.exited;
const content = await fs.readFileSync(tmpFile);
expect(content.toString()).toBe(`const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({
headless: false
});
const context = await browser.newContext();
// Open new page
const page = await context.newPage();
// Go to ${emptyHTML}
await page.goto('${emptyHTML}');
// Close page
await page.close();
// ---------------------
await context.close();
await browser.close();
})();`);
});
it('should print load/save storageState', async ({ runCLI, testInfo }) => {
const loadFileName = testInfo.outputPath('load.json');
const saveFileName = testInfo.outputPath('save.json');
await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8');
const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, 'codegen', emptyHTML]);
const expectedResult = `const { chromium, devices } = require('playwright');
(async () => {
const browser = await chromium.launch({
headless: false
});
const context = await browser.newContext({
storageState: '${loadFileName}'
});
// ---------------------
await context.storageState({ path: '${saveFileName}' });
await context.close();
await browser.close();
})();`;
await cli.waitFor(expectedResult);
});

View File

@ -0,0 +1,129 @@
/**
* 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 fs from 'fs';
import * as path from 'path';
import { folio } from './cli.fixtures';
const { it, expect } = folio;
const emptyHTML = new URL('file://' + path.join(__dirname, '..', 'assets', 'empty.html')).toString();
it('should print the correct imports and context options', async ({ runCLI }) => {
const cli = runCLI(['codegen', '--target=python-async', emptyHTML]);
const expectedResult = `import asyncio
from playwright import async_playwright
async def run(playwright):
browser = await playwright.chromium.launch(headless=False)
context = await browser.newContext()`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options for custom settings', async ({ runCLI }) => {
const cli = runCLI(['--color-scheme=light', 'codegen', '--target=python-async', emptyHTML]);
const expectedResult = `import asyncio
from playwright import async_playwright
async def run(playwright):
browser = await playwright.chromium.launch(headless=False)
context = await browser.newContext(colorScheme="light")`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options when using a device', async ({ runCLI }) => {
const cli = runCLI(['--device=Pixel 2', 'codegen', '--target=python-async', emptyHTML]);
const expectedResult = `import asyncio
from playwright import async_playwright
async def run(playwright):
browser = await playwright.chromium.launch(headless=False)
context = await browser.newContext(**playwright.devices["Pixel 2"])`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options when using a device and additional options', async ({ runCLI }) => {
const cli = runCLI(['--color-scheme=light', '--device=Pixel 2', 'codegen', '--target=python-async', emptyHTML]);
const expectedResult = `import asyncio
from playwright import async_playwright
async def run(playwright):
browser = await playwright.chromium.launch(headless=False)
context = await browser.newContext(**playwright.devices["Pixel 2"], colorScheme="light")`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should save the codegen output to a file if specified', async ({ runCLI, testInfo }) => {
const tmpFile = testInfo.outputPath('script.js');
const cli = runCLI(['codegen', '--target=python-async', '--output', tmpFile, emptyHTML]);
await cli.exited;
const content = await fs.readFileSync(tmpFile);
expect(content.toString()).toBe(`import asyncio
from playwright import async_playwright
async def run(playwright):
browser = await playwright.chromium.launch(headless=False)
context = await browser.newContext()
# Open new page
page = await context.newPage()
# Go to ${emptyHTML}
await page.goto("${emptyHTML}")
# Close page
await page.close()
# ---------------------
await context.close()
await browser.close()
async def main():
async with async_playwright() as playwright:
await run(playwright)
asyncio.run(main())`);
});
it('should print load/save storageState', async ({ runCLI, testInfo }) => {
const loadFileName = testInfo.outputPath('load.json');
const saveFileName = testInfo.outputPath('save.json');
await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8');
const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, 'codegen', '--target=python-async', emptyHTML]);
const expectedResult = `import asyncio
from playwright import async_playwright
async def run(playwright):
browser = await playwright.chromium.launch(headless=False)
context = await browser.newContext(storageState="${loadFileName}")
# Open new page
page = await context.newPage()
# ---------------------
await context.storageState(path="${saveFileName}")
await context.close()
await browser.close()
async def main():
async with async_playwright() as playwright:
await run(playwright)
asyncio.run(main())`;
await cli.waitFor(expectedResult);
});

View File

@ -0,0 +1,119 @@
/**
* 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 fs from 'fs';
import * as path from 'path';
import { folio } from './cli.fixtures';
const { it, expect } = folio;
const emptyHTML = new URL('file://' + path.join(__dirname, '..', 'assets', 'empty.html')).toString();
it('should print the correct imports and context options', async ({ runCLI }) => {
const cli = runCLI(['codegen', '--target=python', emptyHTML]);
const expectedResult = `from playwright import sync_playwright
def run(playwright):
browser = playwright.chromium.launch(headless=False)
context = browser.newContext()`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options for custom settings', async ({ runCLI }) => {
const cli = runCLI(['--color-scheme=light', 'codegen', '--target=python', emptyHTML]);
const expectedResult = `from playwright import sync_playwright
def run(playwright):
browser = playwright.chromium.launch(headless=False)
context = browser.newContext(colorScheme="light")`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options when using a device', async ({ runCLI }) => {
const cli = runCLI(['--device=Pixel 2', 'codegen', '--target=python', emptyHTML]);
const expectedResult = `from playwright import sync_playwright
def run(playwright):
browser = playwright.chromium.launch(headless=False)
context = browser.newContext(**playwright.devices["Pixel 2"])`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should print the correct context options when using a device and additional options', async ({ runCLI }) => {
const cli = runCLI(['--color-scheme=light', '--device=Pixel 2', 'codegen', '--target=python', emptyHTML]);
const expectedResult = `from playwright import sync_playwright
def run(playwright):
browser = playwright.chromium.launch(headless=False)
context = browser.newContext(**playwright.devices["Pixel 2"], colorScheme="light")`;
await cli.waitFor(expectedResult);
expect(cli.text()).toContain(expectedResult);
});
it('should save the codegen output to a file if specified', async ({ runCLI, testInfo }) => {
const tmpFile = testInfo.outputPath('script.js');
const cli = runCLI(['codegen', '--target=python', '--output', tmpFile, emptyHTML]);
await cli.exited;
const content = fs.readFileSync(tmpFile);
expect(content.toString()).toBe(`from playwright import sync_playwright
def run(playwright):
browser = playwright.chromium.launch(headless=False)
context = browser.newContext()
# Open new page
page = context.newPage()
# Go to ${emptyHTML}
page.goto("${emptyHTML}")
# Close page
page.close()
# ---------------------
context.close()
browser.close()
with sync_playwright() as playwright:
run(playwright)`);
});
it('should print load/save storageState', async ({ runCLI, testInfo }) => {
const loadFileName = testInfo.outputPath('load.json');
const saveFileName = testInfo.outputPath('save.json');
await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8');
const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, 'codegen', '--target=python', emptyHTML]);
const expectedResult = `from playwright import sync_playwright
def run(playwright):
browser = playwright.chromium.launch(headless=False)
context = browser.newContext(storageState="${loadFileName}")
# Open new page
page = context.newPage()
# ---------------------
context.storageState(path="${saveFileName}")
context.close()
browser.close()
with sync_playwright() as playwright:
run(playwright)`;
await cli.waitFor(expectedResult);
});

View File

@ -0,0 +1,581 @@
/**
* 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 { folio } from './cli.fixtures';
import * as http from 'http';
import * as url from 'url';
const { it, expect } = folio;
it('should click', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<button onclick="console.log('click')">Submit</button>`);
const selector = await recorder.hoverOverElement('button');
expect(selector).toBe('text="Submit"');
const [message] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('click'),
page.dispatchEvent('button', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
// Click text="Submit"
await page.click('text="Submit"');`);
expect(message.text()).toBe('click');
});
it('should not target selector preview by text regexp', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<span>dummy</span>`);
// Force highlight.
await recorder.hoverOverElement('span');
// Append text after highlight.
await page.evaluate(() => {
const div = document.createElement('div');
div.setAttribute('onclick', "console.log('click')");
div.textContent = ' Some long text here ';
document.documentElement.appendChild(div);
});
const selector = await recorder.hoverOverElement('div');
expect(selector).toBe('text=/.*Some long text here.*/');
// Sanity check that selector does not match our highlight.
const divContents = await page.$eval(selector, div => div.outerHTML);
expect(divContents).toBe(`<div onclick="console.log('click')"> Some long text here </div>`);
const [message] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('click'),
page.dispatchEvent('div', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
// Click text=/.*Some long text here.*/
await page.click('text=/.*Some long text here.*/');`);
expect(message.text()).toBe('click');
});
it('should fill', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input id="input" name="name" oninput="console.log(input.value)"></input>`);
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="name"]');
const [message] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('fill'),
page.fill('input', 'John')
]);
expect(recorder.output()).toContain(`
// Fill input[name="name"]
await page.fill('input[name="name"]', 'John');`);
expect(message.text()).toBe('John');
});
it('should fill textarea', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<textarea id="textarea" name="name" oninput="console.log(textarea.value)"></textarea>`);
const selector = await recorder.focusElement('textarea');
expect(selector).toBe('textarea[name="name"]');
const [message] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('fill'),
page.fill('textarea', 'John')
]);
expect(recorder.output()).toContain(`
// Fill textarea[name="name"]
await page.fill('textarea[name="name"]', 'John');`);
expect(message.text()).toBe('John');
});
it('should press', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input name="name" onkeypress="console.log('press')"></input>`);
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="name"]');
const messages: any[] = [];
page.on('console', message => messages.push(message)),
await Promise.all([
recorder.waitForActionPerformed(),
recorder.waitForOutput('press'),
page.press('input', 'Shift+Enter')
]);
expect(recorder.output()).toContain(`
// Press Enter with modifiers
await page.press('input[name="name"]', 'Shift+Enter');`);
expect(messages[0].text()).toBe('press');
});
it('should update selected element after pressing Tab', async ({ page, recorder }) => {
await recorder.setContentAndWait(`
<input name="one"></input>
<input name="two"></input>
`);
await page.click('input[name="one"]');
await recorder.waitForOutput('click');
await page.keyboard.type('foobar123');
await recorder.waitForOutput('foobar123');
await page.keyboard.press('Tab');
await recorder.waitForOutput('Tab');
await page.keyboard.type('barfoo321');
await recorder.waitForOutput('barfoo321');
expect(recorder.output()).toContain(`
// Fill input[name="one"]
await page.fill('input[name="one"]', 'foobar123');`);
expect(recorder.output()).toContain(`
// Press Tab
await page.press('input[name="one"]', 'Tab');`);
expect(recorder.output()).toContain(`
// Fill input[name="two"]
await page.fill('input[name="two"]', 'barfoo321');`);
});
it('should record ArrowDown', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input name="name" onkeydown="console.log('press:' + event.key)"></input>`);
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="name"]');
const messages: any[] = [];
page.on('console', message => {
messages.push(message);
}),
await Promise.all([
recorder.waitForActionPerformed(),
recorder.waitForOutput('press'),
page.press('input', 'ArrowDown')
]);
expect(recorder.output()).toContain(`
// Press ArrowDown
await page.press('input[name="name"]', 'ArrowDown');`);
expect(messages[0].text()).toBe('press:ArrowDown');
});
it('should emit single keyup on ArrowDown', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input name="name" onkeydown="console.log('down:' + event.key)" onkeyup="console.log('up:' + event.key)"></input>`);
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="name"]');
const messages: any[] = [];
page.on('console', message => {
messages.push(message);
}),
await Promise.all([
recorder.waitForActionPerformed(),
recorder.waitForOutput('press'),
page.press('input', 'ArrowDown')
]);
expect(recorder.output()).toContain(`
// Press ArrowDown
await page.press('input[name="name"]', 'ArrowDown');`);
expect(messages.length).toBe(2);
expect(messages[0].text()).toBe('down:ArrowDown');
expect(messages[1].text()).toBe('up:ArrowDown');
});
it('should check', (test, { browserName, headful }) => {
test.fixme(browserName === 'firefox' && headful, 'Focus is off');
}, async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" name="accept" onchange="console.log(checkbox.checked)"></input>`);
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="accept"]');
const [message] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('check'),
page.click('input')
]);
await recorder.waitForOutput('check');
expect(recorder.output()).toContain(`
// Check input[name="accept"]
await page.check('input[name="accept"]');`);
expect(message.text()).toBe('true');
});
it('should check with keyboard', (test, { browserName, headful }) => {
test.fixme(browserName === 'firefox' && headful, 'Focus is off');
}, async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" name="accept" onchange="console.log(checkbox.checked)"></input>`);
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="accept"]');
const [message] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('check'),
page.keyboard.press('Space')
]);
await recorder.waitForOutput('check');
expect(recorder.output()).toContain(`
// Check input[name="accept"]
await page.check('input[name="accept"]');`);
expect(message.text()).toBe('true');
});
it('should uncheck', (test, { browserName, headful }) => {
test.fixme(browserName === 'firefox' && headful, 'Focus is off');
}, async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" checked name="accept" onchange="console.log(checkbox.checked)"></input>`);
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="accept"]');
const [message] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('uncheck'),
page.click('input')
]);
expect(recorder.output()).toContain(`
// Uncheck input[name="accept"]
await page.uncheck('input[name="accept"]');`);
expect(message.text()).toBe('false');
});
it('should select', async ({ page, recorder }) => {
await recorder.setContentAndWait('<select id="age" onchange="console.log(age.selectedOptions[0].value)"><option value="1"><option value="2"></select>');
const selector = await recorder.hoverOverElement('select');
expect(selector).toBe('select[id="age"]');
const [message] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('select'),
page.selectOption('select', '2')
]);
expect(recorder.output()).toContain(`
// Select 2
await page.selectOption('select[id="age"]', '2');`);
expect(message.text()).toBe('2');
});
it('should await popup', (test, { browserName, headful }) => {
test.fixme(browserName === 'webkit' && headful, 'Middle click does not open a popup in our webkit embedder');
}, async ({ page, recorder }) => {
await recorder.setContentAndWait('<a target=_blank rel=noopener href="about:blank">link</a>');
const selector = await recorder.hoverOverElement('a');
expect(selector).toBe('text="link"');
const [popup] = await Promise.all([
page.context().waitForEvent('page'),
recorder.waitForOutput('waitForEvent'),
page.dispatchEvent('a', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
// Click text="link"
const [page1] = await Promise.all([
page.waitForEvent('popup'),
page.click('text="link"')
]);`);
expect(popup.url()).toBe('about:blank');
});
it('should assert navigation', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
const selector = await recorder.hoverOverElement('a');
expect(selector).toBe('text="link"');
await Promise.all([
page.waitForNavigation(),
recorder.waitForOutput('assert'),
page.dispatchEvent('a', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
// Click text="link"
await page.click('text="link"');
// assert.equal(page.url(), 'about:blank#foo');`);
expect(page.url()).toContain('about:blank#foo');
});
it('should await navigation', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<a onclick="setTimeout(() => window.location.href='about:blank#foo', 1000)">link</a>`);
const selector = await recorder.hoverOverElement('a');
expect(selector).toBe('text="link"');
await Promise.all([
page.waitForNavigation(),
recorder.waitForOutput('waitForNavigation'),
page.dispatchEvent('a', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
// Click text="link"
await Promise.all([
page.waitForNavigation(/*{ url: 'about:blank#foo' }*/),
page.click('text="link"')
]);`);
expect(page.url()).toContain('about:blank#foo');
});
it('should contain open page', async ({ recorder }) => {
await recorder.setContentAndWait(``);
expect(recorder.output()).toContain(`const page = await context.newPage();`);
});
it('should contain second page', async ({ contextWrapper, recorder }) => {
await recorder.setContentAndWait(``);
await contextWrapper.context.newPage();
await recorder.waitForOutput('page1');
expect(recorder.output()).toContain('const page1 = await context.newPage();');
});
it('should contain close page', async ({ contextWrapper, recorder }) => {
await recorder.setContentAndWait(``);
await contextWrapper.context.newPage();
await recorder.page.close();
await recorder.waitForOutput('page.close();');
});
it('should not lead to an error if /html gets clicked', async ({ contextWrapper, recorder }) => {
await recorder.setContentAndWait('');
await contextWrapper.context.newPage();
const errors: any[] = [];
recorder.page.on('pageerror', e => errors.push(e));
await recorder.page.evaluate(() => document.querySelector('body').remove());
const selector = await recorder.hoverOverElement('html');
expect(selector).toBe('/html');
await recorder.page.close();
await recorder.waitForOutput('page.close();');
expect(errors.length).toBe(0);
});
it('should upload a single file', async ({ page, recorder }) => {
await recorder.setContentAndWait(`
<form>
<input type="file">
</form>
`);
await page.focus('input[type=file]');
await page.setInputFiles('input[type=file]', 'test/assets/file-to-upload.txt');
await page.click('input[type=file]');
await recorder.waitForOutput('setInputFiles');
expect(recorder.output()).toContain(`
// Upload file-to-upload.txt
await page.setInputFiles('input[type="file"]', 'file-to-upload.txt');`);
});
it('should upload multiple files', async ({ page, recorder }) => {
await recorder.setContentAndWait(`
<form>
<input type="file" multiple>
</form>
`);
await page.focus('input[type=file]');
await page.setInputFiles('input[type=file]', ['test/assets/file-to-upload.txt', 'test/assets/file-to-upload-2.txt']);
await page.click('input[type=file]');
await recorder.waitForOutput('setInputFiles');
expect(recorder.output()).toContain(`
// Upload file-to-upload.txt, file-to-upload-2.txt
await page.setInputFiles('input[type="file"]', ['file-to-upload.txt', 'file-to-upload-2.txt']);`);
});
it('should clear files', async ({ page, recorder }) => {
await recorder.setContentAndWait(`
<form>
<input type="file" multiple>
</form>
`);
await page.focus('input[type=file]');
await page.setInputFiles('input[type=file]', 'test/assets/file-to-upload.txt');
await page.setInputFiles('input[type=file]', []);
await page.click('input[type=file]');
await recorder.waitForOutput('setInputFiles');
expect(recorder.output()).toContain(`
// Clear selected files
await page.setInputFiles('input[type="file"]', []);`);
});
it('should download files', async ({ page, recorder, httpServer }) => {
httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => {
const pathName = url.parse(req.url!).path;
if (pathName === '/download') {
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=file.txt');
res.end(`Hello world`);
} else {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end('');
}
});
await recorder.setContentAndWait(`
<a href="${httpServer.PREFIX}/download" download>Download</a>
`, httpServer.PREFIX);
await recorder.hoverOverElement('text=Download');
await Promise.all([
page.waitForEvent('download'),
page.click('text=Download')
]);
await recorder.waitForOutput('page.click');
expect(recorder.output()).toContain(`
// Click text="Download"
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('text="Download"')
]);`);
});
it('should handle dialogs', async ({ page, recorder }) => {
await recorder.setContentAndWait(`
<button onclick="alert()">click me</button>
`);
await recorder.hoverOverElement('button');
page.once('dialog', async dialog => {
await dialog.dismiss();
});
await page.click('text="click me"');
await recorder.waitForOutput('page.once');
expect(recorder.output()).toContain(`
// Click text="click me"
page.once('dialog', dialog => {
console.log(\`Dialog message: $\{dialog.message()}\`);
dialog.dismiss().catch(() => {});
});
await page.click('text="click me"')`);
});
it('should handle history.postData', async ({ page, recorder, httpServer }) => {
httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end('Hello world');
});
await recorder.setContentAndWait(`
<script>
let seqNum = 0;
function pushState() {
history.pushState({}, 'title', '${httpServer.PREFIX}/#seqNum=' + (++seqNum));
}
</script>`, httpServer.PREFIX);
for (let i = 1; i < 3; ++i) {
await page.evaluate('pushState()');
await recorder.waitForOutput(`seqNum=${i}`);
expect(recorder.output()).toContain(`await page.goto('${httpServer.PREFIX}/#seqNum=${i}');`);
}
});
it('should record open in a new tab with url', (test, { browserName }) => {
test.fixme(browserName === 'webkit', 'Ctrl+click does not open in new tab on WebKit');
}, async ({ page, recorder, browserName, platform }) => {
await recorder.setContentAndWait(`<a href="about:blank?foo">link</a>`);
const selector = await recorder.hoverOverElement('a');
expect(selector).toBe('text="link"');
await page.click('a', { modifiers: [ platform === 'darwin' ? 'Meta' : 'Control'] });
await recorder.waitForOutput('page1');
if (browserName === 'chromium') {
expect(recorder.output()).toContain(`
// Open new page
const page1 = await context.newPage();
page1.goto('about:blank?foo');`);
} else if (browserName === 'firefox') {
expect(recorder.output()).toContain(`
// Click text="link"
const [page1] = await Promise.all([
page.waitForEvent('popup'),
page.click('text="link"', {
modifiers: ['${platform === 'darwin' ? 'Meta' : 'Control'}']
})
]);`);
}
});
it('should not clash pages', (test, { browserName }) => {
test.fixme(browserName === 'firefox', 'Times out on Firefox, maybe the focus issue');
}, async ({ page, recorder }) => {
const [popup1] = await Promise.all([
page.context().waitForEvent('page'),
page.evaluate(`window.open('about:blank')`)
]);
await recorder.setPageContentAndWait(popup1, '<input id=name>');
const [popup2] = await Promise.all([
page.context().waitForEvent('page'),
page.evaluate(`window.open('about:blank')`)
]);
await recorder.setPageContentAndWait(popup2, '<input id=name>');
await popup1.type('input', 'TextA');
await recorder.waitForOutput('TextA');
await popup2.type('input', 'TextB');
await recorder.waitForOutput('TextB');
expect(recorder.output()).toContain(`await page1.fill('input[id="name"]', 'TextA');`);
expect(recorder.output()).toContain(`await page2.fill('input[id="name"]', 'TextB');`);
});
it('click should emit events in order', async ({ page, recorder }) => {
await recorder.setContentAndWait(`
<button id=button>
<script>
button.addEventListener('mousedown', e => console.log(e.type));
button.addEventListener('mouseup', e => console.log(e.type));
button.addEventListener('click', e => console.log(e.type));
</script>
`);
const messages: any[] = [];
page.on('console', message => messages.push(message.text()));
await Promise.all([
page.click('button'),
recorder.waitForOutput('page.click')
]);
expect(messages).toEqual(['mousedown', 'mouseup', 'click']);
});
it('should update hover model on action', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" name="accept" onchange="checkbox.name='updated'"></input>`);
const [ models ] = await Promise.all([
recorder.waitForActionPerformed(),
page.click('input')
]);
expect(models.hovered).toBe('input[name="updated"]');
});
it('should update active model on action', (test, { browserName, headful }) => {
test.fixme(browserName === 'webkit' && !headful);
test.fixme(browserName === 'firefox' && !headful);
}, async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" name="accept" onchange="checkbox.name='updated'"></input>`);
const [ models ] = await Promise.all([
recorder.waitForActionPerformed(),
page.click('input')
]);
expect(models.active).toBe('input[name="updated"]');
});
it('should check input with chaning id', async ({ page, recorder }) => {
await recorder.setContentAndWait(`<input id="checkbox" type="checkbox" name="accept" onchange="checkbox.name = 'updated'"></input>`);
await Promise.all([
recorder.waitForActionPerformed(),
page.click('input[id=checkbox]')
]);
});

254
test/cli/cli.fixtures.ts Normal file
View File

@ -0,0 +1,254 @@
/**
* 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 http from 'http';
import { Writable } from 'stream';
import * as path from 'path';
import { ChildProcess, spawn } from 'child_process';
import { folio as baseFolio } from '../fixtures';
import type { Page, BrowserType, Browser, BrowserContext } from '../..';
export { config } from 'folio';
import { RecorderController } from '../../src/cli/codegen/recorderController';
import { TerminalOutput } from '../../src/cli/codegen/outputs';
import { JavaScriptLanguageGenerator } from '../../src/cli/codegen/languages';
import { CodeGenerator } from '../../src/cli/codegen/codeGenerator';
type WorkerFixtures = {
browserType: BrowserType<Browser>;
browser: Browser;
httpServer: httpServer;
};
type TestFixtures = {
contextWrapper: { context: BrowserContext, output: WritableBuffer };
recorder: Recorder;
runCLI: (args: string[]) => CLIMock;
};
export const fixtures = baseFolio.extend<TestFixtures, WorkerFixtures>();
fixtures.contextWrapper.init(async ({ browser }, runTest) => {
const context = await browser.newContext();
const outputBuffer = new WritableBuffer();
const output = new TerminalOutput(outputBuffer as any as Writable, 'javascript');
const languageGenerator = new JavaScriptLanguageGenerator();
const generator = new CodeGenerator('chromium', {}, {}, output, languageGenerator, undefined, undefined);
new RecorderController(context, generator);
await runTest({ context, output: outputBuffer });
await context.close();
});
fixtures.recorder.init(async ({ contextWrapper }, runTest) => {
const page = await contextWrapper.context.newPage();
if (process.env.PWCONSOLE)
page.on('console', console.log);
await runTest(new Recorder(page, contextWrapper.output));
await page.close();
});
fixtures.httpServer.init(async ({testWorkerIndex}, runTest) => {
let handler = (req: http.IncomingMessage, res: http.ServerResponse) => res.end();
const port = 9907 + testWorkerIndex;
const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => handler(req, res)).listen(port);
await runTest({
setHandler: newHandler => handler = newHandler,
PREFIX: `http://127.0.0.1:${port}`,
});
server.close();
}, { scope: 'worker' });
fixtures.page.override(async ({ recorder }, runTest) => {
await runTest(recorder.page);
});
function removeAnsiColors(input: string): string {
const pattern = [
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
'(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))'
].join('|');
return input.replace(new RegExp(pattern, 'g'), '');
}
class WritableBuffer {
_data: string;
private _callback: () => void;
_text: string;
constructor() {
this._data = '';
}
write(chunk: string) {
if (!chunk)
return;
if (chunk === '\u001B[F\u001B[2K') {
const index = this._data.lastIndexOf('\n');
this._data = this._data.substring(0, index);
return;
}
this._data += chunk;
if (this._callback && chunk.includes(this._text))
this._callback();
}
_waitFor(text: string): Promise<void> {
if (this._data.includes(text))
return Promise.resolve();
this._text = text;
return new Promise(f => this._callback = f);
}
data() {
return this._data;
}
text() {
return removeAnsiColors(this.data());
}
}
class Recorder {
page: Page;
_output: WritableBuffer;
_highlightCallback: Function
_highlightInstalled: boolean
_actionReporterInstalled: boolean
_actionPerformedCallback: Function
constructor(page: Page, output: WritableBuffer) {
this.page = page;
this._output = output;
this._highlightCallback = () => { };
this._highlightInstalled = false;
this._actionReporterInstalled = false;
this._actionPerformedCallback = () => { };
}
async setContentAndWait(content: string, url: string = 'about:blank') {
await this.setPageContentAndWait(this.page, content, url);
}
async setPageContentAndWait(page: Page, content: string, url: string = 'about:blank') {
let callback;
const result = new Promise(f => callback = f);
await page.goto(url);
await page.exposeBinding('_recorderScriptReadyForTest', (source, arg) => callback(arg));
await Promise.all([
result,
page.setContent(content)
]);
}
async waitForOutput(text: string): Promise<void> {
await this._output._waitFor(text);
}
output(): string {
return this._output.text();
}
async waitForHighlight(action: () => Promise<void>): Promise<string> {
if (!this._highlightInstalled) {
this._highlightInstalled = true;
await this.page.exposeBinding('_highlightUpdatedForTest', (source, arg) => this._highlightCallback(arg));
}
const [ generatedSelector ] = await Promise.all([
new Promise<string>(f => this._highlightCallback = f),
action()
]);
return generatedSelector;
}
async waitForActionPerformed(): Promise<{ hovered: string | null, active: string | null }> {
if (!this._actionReporterInstalled) {
this._actionReporterInstalled = true;
await this.page.exposeBinding('_actionPerformedForTest', (source, arg) => this._actionPerformedCallback(arg));
}
return await new Promise(f => this._actionPerformedCallback = f);
}
async hoverOverElement(selector: string): Promise<string> {
return this.waitForHighlight(() => this.page.dispatchEvent(selector, 'mousemove', { detail: 1 }));
}
async focusElement(selector: string): Promise<string> {
return this.waitForHighlight(() => this.page.focus(selector));
}
}
fixtures.runCLI.init(async ({ }, runTest) => {
let cli: CLIMock;
const cliFactory = (args: string[]) => {
cli = new CLIMock(args);
return cli;
};
await runTest(cliFactory);
cli.kill();
});
class CLIMock {
private process: ChildProcess;
private data: string;
private waitForText: string;
private waitForCallback: () => void;
exited: Promise<void>;
constructor(args: string[]) {
this.data = '';
this.process = spawn('node', [
path.join(__dirname, '..', '..', 'lib', 'cli', 'cli.js'),
...args
], {
env: {
...process.env,
PWCLI_EXIT_FOR_TEST: '1'
}
});
this.process.stdout.on('data', line => {
this.data += removeAnsiColors(line.toString());
if (this.waitForCallback && this.data.includes(this.waitForText))
this.waitForCallback();
});
this.exited = new Promise<void>(r => this.process.on('exit', () => {
if (this.waitForCallback)
this.waitForCallback();
return r();
}));
}
async waitFor(text: string): Promise<void> {
if (this.data.includes(text))
return Promise.resolve();
this.waitForText = text;
return new Promise(f => this.waitForCallback = f);
}
text() {
return removeAnsiColors(this.data);
}
kill() {
this.process.kill();
}
}
interface httpServer {
setHandler: (handler: http.RequestListener) => void
PREFIX: string
}
export const folio = fixtures.build();

View File

@ -136,7 +136,7 @@ DEPS['src/remote/'] = ['src/client/', 'src/debug/', 'src/dispatchers/', 'src/ser
DEPS['src/service.ts'] = ['src/remote/'];
// CLI should only use client-side features.
DEPS['src/cli/'] = ['src/client/**', 'src/install/**', 'src/generated/'];
DEPS['src/cli/'] = ['src/cli/**', 'src/client/**', 'src/install/**', 'src/generated/', 'src/server/injected/', 'src/debug/injected/'];
checkDeps().catch(e => {
console.error(e && e.stack ? e.stack : e);

View File

@ -21,6 +21,7 @@ const files = [
path.join('src', 'server', 'injected', 'injectedScript.webpack.config.js'),
path.join('src', 'server', 'injected', 'utilityScript.webpack.config.js'),
path.join('src', 'debug', 'injected', 'consoleApi.webpack.config.js'),
path.join('src', 'cli', 'injected', 'recorder.webpack.config.js'),
];
function runOne(runner, file) {