mirror of
https://github.com/microsoft/playwright.git
synced 2024-09-11 20:37:54 +03:00
feat(cli): bring in codegen and tests (#4815)
This commit is contained in:
parent
4c11f5d885
commit
293a7bdd4c
5
package-lock.json
generated
5
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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];
|
||||
|
137
src/cli/codegen/codeGenerator.ts
Normal file
137
src/cli/codegen/codeGenerator.ts
Normal 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: [],
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
314
src/cli/codegen/languages/csharp.ts
Normal file
314
src/cli/codegen/languages/csharp.ts
Normal 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, '\\"')}"`;
|
||||
}
|
29
src/cli/codegen/languages/index.ts
Normal file
29
src/cli/codegen/languages/index.ts
Normal 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';
|
266
src/cli/codegen/languages/javascript.ts
Normal file
266
src/cli/codegen/languages/javascript.ts
Normal 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');
|
||||
}
|
279
src/cli/codegen/languages/python.ts
Normal file
279
src/cli/codegen/languages/python.ts
Normal 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
114
src/cli/codegen/outputs.ts
Normal 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(/'/g, "'");
|
||||
highlightedCode = highlightedCode.replace(/"/g, '"');
|
||||
highlightedCode = highlightedCode.replace(/>/g, '>');
|
||||
highlightedCode = highlightedCode.replace(/</g, '<');
|
||||
highlightedCode = highlightedCode.replace(/&/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() {}
|
||||
}
|
149
src/cli/codegen/recorderActions.ts
Normal file
149
src/cli/codegen/recorderActions.ts
Normal 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(', ')}`;
|
||||
}
|
||||
}
|
164
src/cli/codegen/recorderController.ts
Normal file
164
src/cli/codegen/recorderController.ts
Normal 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
48
src/cli/codegen/utils.ts
Normal 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;
|
||||
}
|
514
src/cli/injected/recorder.ts
Normal file
514
src/cli/injected/recorder.ts
Normal 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;
|
46
src/cli/injected/recorder.webpack.config.js
Normal file
46
src/cli/injected/recorder.webpack.config.js
Normal 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')),
|
||||
]
|
||||
};
|
1
test/assets/file-to-upload-2.txt
Normal file
1
test/assets/file-to-upload-2.txt
Normal file
@ -0,0 +1 @@
|
||||
contents of the file
|
149
test/cli/cli-codegen-csharp.spec.ts
Normal file
149
test/cli/cli-codegen-csharp.spec.ts
Normal 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);
|
||||
});
|
134
test/cli/cli-codegen-javascript.spec.ts
Normal file
134
test/cli/cli-codegen-javascript.spec.ts
Normal 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);
|
||||
});
|
129
test/cli/cli-codegen-python-async.spec.ts
Normal file
129
test/cli/cli-codegen-python-async.spec.ts
Normal 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);
|
||||
});
|
119
test/cli/cli-codegen-python.spec.ts
Normal file
119
test/cli/cli-codegen-python.spec.ts
Normal 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);
|
||||
});
|
581
test/cli/cli-codegen.spec.ts
Normal file
581
test/cli/cli-codegen.spec.ts
Normal 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
254
test/cli/cli.fixtures.ts
Normal 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();
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user