chore: simplify code generation (#5466)

This commit is contained in:
Pavel Feldman 2021-02-16 18:13:26 -08:00 committed by GitHub
parent b6bd7c0d6a
commit 30e68f6d1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 673 additions and 417 deletions

View file

@ -14,9 +14,10 @@
* limitations under the License.
*/
import { EventEmitter } from 'events';
import type { BrowserContextOptions, LaunchOptions } from '../../../..';
import { Frame } from '../../frames';
import { LanguageGenerator } from './language';
import { LanguageGenerator, LanguageGeneratorOptions } from './language';
import { Action, Signal } from './recorderActions';
import { describeFrame } from './utils';
@ -29,56 +30,55 @@ export type ActionInContext = {
committed?: boolean;
}
export interface CodeGeneratorOutput {
printLn(text: string): void;
popLn(text: string): void;
}
export class CodeGenerator {
export class CodeGenerator extends EventEmitter {
private _currentAction: ActionInContext | null = null;
private _lastAction: ActionInContext | null = null;
private _lastActionText: string | undefined;
private _languageGenerator: LanguageGenerator;
private _output: CodeGeneratorOutput;
private _headerText = '';
private _footerText = '';
private _actions: ActionInContext[] = [];
private _enabled: boolean;
private _options: LanguageGeneratorOptions;
constructor(browserName: string, generateHeaders: boolean, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, deviceName: string | undefined, saveStorage: string | undefined) {
this._output = output;
this._languageGenerator = languageGenerator;
constructor(browserName: string, generateHeaders: boolean, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName: string | undefined, saveStorage: string | undefined) {
super();
launchOptions = { headless: false, ...launchOptions };
if (generateHeaders) {
this._headerText = this._languageGenerator.generateHeader(browserName, launchOptions, contextOptions, deviceName);
this._footerText = '\n' + this._languageGenerator.generateFooter(saveStorage);
}
this._enabled = generateHeaders;
this._options = { browserName, generateHeaders, launchOptions, contextOptions, deviceName, saveStorage };
this.restart();
}
restart() {
this._currentAction = null;
this._lastAction = null;
if (this._headerText) {
this._output.printLn(this._headerText);
this._output.printLn(this._footerText);
}
this._actions = [];
}
setEnabled(enabled: boolean) {
this._enabled = enabled;
}
addAction(action: ActionInContext) {
if (!this._enabled)
return;
this.willPerformAction(action);
this.didPerformAction(action);
}
willPerformAction(action: ActionInContext) {
if (!this._enabled)
return;
this._currentAction = action;
}
performedActionFailed(action: ActionInContext) {
if (!this._enabled)
return;
if (this._currentAction === action)
this._currentAction = null;
}
didPerformAction(actionInContext: ActionInContext) {
if (!this._enabled)
return;
const { action, pageAlias } = actionInContext;
let eraseLastAction = false;
if (this._lastAction && this._lastAction.pageAlias === pageAlias) {
@ -94,41 +94,39 @@ export class CodeGenerator {
}
if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') {
if (action.url === lastAction.url) {
// Already at a target URL.
this._currentAction = null;
return;
}
}
for (const name of ['check', 'uncheck']) {
// Check and uncheck erase click.
if (lastAction && action.name === name && lastAction.name === 'click') {
if ((action as any).selector === (lastAction as any).selector)
eraseLastAction = true;
}
}
}
this._printAction(actionInContext, eraseLastAction);
this._lastAction = actionInContext;
this._currentAction = null;
if (eraseLastAction)
this._actions.pop();
this._actions.push(actionInContext);
this.emit('change');
}
commitLastAction() {
if (!this._enabled)
return;
const action = this._lastAction;
if (action)
action.committed = true;
}
_printAction(actionInContext: ActionInContext, eraseLastAction: boolean) {
if (this._footerText)
this._output.popLn(this._footerText);
if (eraseLastAction && this._lastActionText)
this._output.popLn(this._lastActionText);
const performingAction = !!this._currentAction;
this._currentAction = null;
this._lastAction = actionInContext;
this._lastActionText = this._languageGenerator.generateAction(actionInContext, performingAction);
this._output.printLn(this._lastActionText);
if (this._footerText)
this._output.printLn(this._footerText);
}
signal(pageAlias: string, frame: Frame, signal: Signal) {
if (!this._enabled)
return;
// Signal either arrives while action is being performed or shortly after.
if (this._currentAction) {
this._currentAction.action.signals.push(signal);
@ -140,8 +138,9 @@ export class CodeGenerator {
return;
if (signal.name === 'download' && signals.length && signals[signals.length - 1].name === 'navigation')
signals.length = signals.length - 1;
signal.isAsync = true;
this._lastAction.action.signals.push(signal);
this._printAction(this._lastAction, true);
this.emit('change');
return;
}
@ -154,8 +153,19 @@ export class CodeGenerator {
name: 'navigate',
url: frame.url(),
signals: [],
}
},
});
}
}
generateText(languageGenerator: LanguageGenerator) {
const text = [];
if (this._options.generateHeaders)
text.push(languageGenerator.generateHeader(this._options));
for (const action of this._actions)
text.push(languageGenerator.generateAction(action));
if (this._options.generateHeaders)
text.push(languageGenerator.generateFooter(this._options.saveStorage));
return text.join('\n');
}
}

View file

@ -14,16 +14,19 @@
* limitations under the License.
*/
import type { BrowserContextOptions, LaunchOptions } from '../../../..';
import { LanguageGenerator, sanitizeDeviceOptions } from './language';
import type { BrowserContextOptions } from '../../../..';
import { LanguageGenerator, LanguageGeneratorOptions, sanitizeDeviceOptions, toSignalMap } from './language';
import { ActionInContext } from './codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from './recorderActions';
import { actionTitle, Action } from './recorderActions';
import { MouseClickOptions, toModifiers } from './utils';
import deviceDescriptors = require('../../deviceDescriptors');
export class CSharpLanguageGenerator implements LanguageGenerator {
id = 'csharp';
fileName = '<csharp>';
highlighter = 'csharp';
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
generateAction(actionInContext: ActionInContext): string {
const { action, pageAlias } = actionInContext;
const formatter = new CSharpFormatter(0);
formatter.newLine();
@ -41,63 +44,47 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
`${pageAlias}.GetFrame(name: ${quote(actionInContext.frameName)})` :
`${pageAlias}.GetFrame(url: ${quote(actionInContext.frameUrl)})`);
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;
}
const signals = toSignalMap(action);
if (dialogSignal) {
formatter.add(` void ${pageAlias}_Dialog${dialogSignal.dialogAlias}_EventHandler(object sender, DialogEventArgs e)
if (signals.dialog) {
formatter.add(` void ${pageAlias}_Dialog${signals.dialog.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${signals.dialog.dialogAlias}_EventHandler;
}
${pageAlias}.Dialog += ${pageAlias}_Dialog${dialogSignal.dialogAlias}_EventHandler;`);
${pageAlias}.Dialog += ${pageAlias}_Dialog${signals.dialog.dialogAlias}_EventHandler;`);
}
const waitForNavigation = navigationSignal && !performingAction;
const assertNavigation = navigationSignal && performingAction;
const emitTaskWhenAll = waitForNavigation || popupSignal || downloadSignal;
const emitTaskWhenAll = signals.waitForNavigation || signals.popup || signals.download;
if (emitTaskWhenAll) {
if (popupSignal)
formatter.add(`var ${popupSignal.popupAlias}Task = ${pageAlias}.WaitForEventAsync(PageEvent.Popup)`);
else if (downloadSignal)
if (signals.popup)
formatter.add(`var ${signals.popup.popupAlias}Task = ${pageAlias}.WaitForEventAsync(PageEvent.Popup)`);
else if (signals.download)
formatter.add(`var downloadTask = ${pageAlias}.WaitForEventAsync(PageEvent.Download);`);
formatter.add(`await Task.WhenAll(`);
}
// Popup signals.
if (popupSignal)
formatter.add(`${popupSignal.popupAlias}Task,`);
if (signals.popup)
formatter.add(`${signals.popup.popupAlias}Task,`);
// Navigation signal.
if (waitForNavigation)
formatter.add(`${pageAlias}.WaitForNavigationAsync(/*${quote(navigationSignal!.url)}*/),`);
if (signals.waitForNavigation)
formatter.add(`${pageAlias}.WaitForNavigationAsync(/*${quote(signals.waitForNavigation.url)}*/),`);
// Download signals.
if (downloadSignal)
if (signals.download)
formatter.add(`downloadTask,`);
const prefix = (popupSignal || waitForNavigation || downloadSignal) ? '' : 'await ';
const prefix = (signals.popup || signals.waitForNavigation || signals.download) ? '' : '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);`);
if (signals.assertNavigation)
formatter.add(` // Assert.Equal(${quote(signals.assertNavigation.url)}, ${pageAlias}.Url);`);
return formatter.format();
}
@ -142,19 +129,19 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
}
}
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string {
generateHeader(options: LanguageGeneratorOptions): 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)});`);
await using var browser = await playwright.${toPascal(options.browserName)}.LaunchAsync(${formatArgs(options.launchOptions)});
var context = await browser.NewContextAsync(${formatContextOptions(options.contextOptions, options.deviceName)});`);
return formatter.format();
}
generateFooter(saveStorage: string | undefined): string {
const storageStateLine = saveStorage ? `\nawait context.StorageStateAsync(path: "${saveStorage}");` : '';
return `// ---------------------${storageStateLine}`;
return `\n// ---------------------${storageStateLine}`;
}
}

View file

@ -14,16 +14,19 @@
* limitations under the License.
*/
import type { BrowserContextOptions, LaunchOptions } from '../../../..';
import { LanguageGenerator, sanitizeDeviceOptions } from './language';
import type { BrowserContextOptions } from '../../../..';
import { LanguageGenerator, LanguageGeneratorOptions, sanitizeDeviceOptions, toSignalMap } from './language';
import { ActionInContext } from './codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from './recorderActions';
import { Action, actionTitle } from './recorderActions';
import { MouseClickOptions, toModifiers } from './utils';
import deviceDescriptors = require('../../deviceDescriptors');
export class JavaScriptLanguageGenerator implements LanguageGenerator {
id = 'javascript';
fileName = '<javascript>';
highlighter = 'javascript';
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
generateAction(actionInContext: ActionInContext): string {
const { action, pageAlias } = actionInContext;
const formatter = new JavaScriptFormatter(2);
formatter.newLine();
@ -41,64 +44,48 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
`${pageAlias}.frame(${formatObject({ name: actionInContext.frameName })})` :
`${pageAlias}.frame(${formatObject({ url: actionInContext.frameUrl })})`);
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;
}
const signals = toSignalMap(action);
if (dialogSignal) {
if (signals.dialog) {
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;
const emitPromiseAll = signals.waitForNavigation || signals.popup || signals.download;
if (emitPromiseAll) {
// Generate either await Promise.all([]) or
// const [popup1] = await Promise.all([]).
let leftHandSide = '';
if (popupSignal)
leftHandSide = `const [${popupSignal.popupAlias}] = `;
else if (downloadSignal)
if (signals.popup)
leftHandSide = `const [${signals.popup.popupAlias}] = `;
else if (signals.download)
leftHandSide = `const [download] = `;
formatter.add(`${leftHandSide}await Promise.all([`);
}
// Popup signals.
if (popupSignal)
if (signals.popup)
formatter.add(`${pageAlias}.waitForEvent('popup'),`);
// Navigation signal.
if (waitForNavigation)
formatter.add(`${pageAlias}.waitForNavigation(/*{ url: ${quote(navigationSignal!.url)} }*/),`);
if (signals.waitForNavigation)
formatter.add(`${pageAlias}.waitForNavigation(/*{ url: ${quote(signals.waitForNavigation.url)} }*/),`);
// Download signals.
if (downloadSignal)
if (signals.download)
formatter.add(`${pageAlias}.waitForEvent('download'),`);
const prefix = (popupSignal || waitForNavigation || downloadSignal) ? '' : 'await ';
const prefix = (signals.popup || signals.waitForNavigation || signals.download) ? '' : 'await ';
const actionCall = this._generateActionCall(action);
const suffix = (waitForNavigation || emitPromiseAll) ? '' : ';';
const suffix = (signals.waitForNavigation || emitPromiseAll) ? '' : ';';
formatter.add(`${prefix}${subject}.${actionCall}${suffix}`);
if (emitPromiseAll)
formatter.add(`]);`);
else if (assertNavigation)
formatter.add(` // assert.equal(${pageAlias}.url(), ${quote(navigationSignal!.url)});`);
else if (signals.assertNavigation)
formatter.add(` // assert.equal(${pageAlias}.url(), ${quote(signals.assertNavigation.url)});`);
return formatter.format();
}
@ -143,20 +130,20 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
}
}
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string {
generateHeader(options: LanguageGeneratorOptions): string {
const formatter = new JavaScriptFormatter();
formatter.add(`
const { ${browserName}${deviceName ? ', devices' : ''} } = require('playwright');
const { ${options.browserName}${options.deviceName ? ', devices' : ''} } = require('playwright');
(async () => {
const browser = await ${browserName}.launch(${formatObjectOrVoid(launchOptions)});
const context = await browser.newContext(${formatContextOptions(contextOptions, deviceName)});`);
const browser = await ${options.browserName}.launch(${formatObjectOrVoid(options.launchOptions)});
const context = await browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName)});`);
return formatter.format();
}
generateFooter(saveStorage: string | undefined): string {
const storageStateLine = saveStorage ? `\n await context.storageState({ path: '${saveStorage}' });` : '';
return ` // ---------------------${storageStateLine}
return `\n // ---------------------${storageStateLine}
await context.close();
await browser.close();
})();`;

View file

@ -16,10 +16,23 @@
import type { BrowserContextOptions, LaunchOptions } from '../../../..';
import { ActionInContext } from './codeGenerator';
import { Action, DialogSignal, DownloadSignal, NavigationSignal, PopupSignal } from './recorderActions';
export type LanguageGeneratorOptions = {
browserName: string;
generateHeaders: boolean;
launchOptions: LaunchOptions;
contextOptions: BrowserContextOptions;
deviceName?: string;
saveStorage?: string;
};
export interface LanguageGenerator {
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string;
generateAction(actionInContext: ActionInContext, performingAction: boolean): string;
id: string;
fileName: string;
highlighter: string;
generateHeader(options: LanguageGeneratorOptions): string;
generateAction(actionInContext: ActionInContext): string;
generateFooter(saveStorage: string | undefined): string;
}
@ -32,3 +45,30 @@ export function sanitizeDeviceOptions(device: any, options: BrowserContextOption
}
return cleanedOptions;
}
export function toSignalMap(action: Action) {
let waitForNavigation: NavigationSignal | undefined;
let assertNavigation: NavigationSignal | undefined;
let popup: PopupSignal | undefined;
let download: DownloadSignal | undefined;
let dialog: DialogSignal | undefined;
for (const signal of action.signals) {
if (signal.name === 'navigation' && signal.isAsync)
waitForNavigation = signal;
else if (signal.name === 'navigation' && !signal.isAsync)
assertNavigation = signal;
else if (signal.name === 'popup')
popup = signal;
else if (signal.name === 'download')
download = signal;
else if (signal.name === 'dialog')
dialog = signal;
}
return {
waitForNavigation,
assertNavigation,
popup,
download,
dialog,
};
}

View file

@ -1,111 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
export interface RecorderOutput {
printLn(text: string): void;
popLn(text: string): void;
flush(): void;
}
export interface Writable {
write(data: string): void;
columns(): number;
}
export class OutputMultiplexer implements RecorderOutput {
private _outputs: RecorderOutput[]
private _enabled = true;
constructor(outputs: RecorderOutput[]) {
this._outputs = outputs;
}
setEnabled(enabled: boolean) {
this._enabled = enabled;
}
printLn(text: string) {
if (!this._enabled)
return;
for (const output of this._outputs)
output.printLn(text);
}
popLn(text: string) {
if (!this._enabled)
return;
for (const output of this._outputs)
output.popLn(text);
}
flush() {
if (!this._enabled)
return;
for (const output of this._outputs)
output.flush();
}
}
export class BufferedOutput implements RecorderOutput {
private _lines: string[] = [];
private _buffer: string | null = null;
private _onUpdate: ((text: string) => void);
constructor(onUpdate: (text: string) => void = () => {}) {
this._onUpdate = onUpdate;
}
printLn(text: string) {
this._buffer = null;
this._lines.push(...text.trimEnd().split('\n'));
this._onUpdate(this.buffer());
}
popLn(text: string) {
this._buffer = null;
this._lines.length -= text.trimEnd().split('\n').length;
}
buffer(): string {
if (this._buffer === null)
this._buffer = this._lines.join('\n');
return this._buffer;
}
clear() {
this._lines = [];
this._buffer = null;
this._onUpdate(this.buffer());
}
flush() {
}
}
export class FileOutput extends BufferedOutput implements RecorderOutput {
private _fileName: string;
constructor(fileName: string) {
super();
this._fileName = fileName;
process.on('exit', () => this.flush());
}
flush() {
fs.writeFileSync(this._fileName, this.buffer());
}
}

View file

@ -14,25 +14,31 @@
* limitations under the License.
*/
import type { BrowserContextOptions, LaunchOptions } from '../../../..';
import { LanguageGenerator, sanitizeDeviceOptions } from './language';
import type { BrowserContextOptions } from '../../../..';
import { LanguageGenerator, LanguageGeneratorOptions, sanitizeDeviceOptions, toSignalMap } from './language';
import { ActionInContext } from './codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from './recorderActions';
import { actionTitle, Action } from './recorderActions';
import { MouseClickOptions, toModifiers } from './utils';
import deviceDescriptors = require('../../deviceDescriptors');
export class PythonLanguageGenerator implements LanguageGenerator {
id = 'python';
fileName = '<python>';
highlighter = 'python';
private _awaitPrefix: '' | 'await ';
private _asyncPrefix: '' | 'async ';
private _isAsync: boolean;
constructor(isAsync: boolean) {
this.id = isAsync ? 'python-async' : 'python';
this.fileName = isAsync ? '<async python>' : '<python>';
this._isAsync = isAsync;
this._awaitPrefix = isAsync ? 'await ' : '';
this._asyncPrefix = isAsync ? 'async ' : '';
}
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
generateAction(actionInContext: ActionInContext): string {
const { action, pageAlias } = actionInContext;
const formatter = new PythonFormatter(4);
formatter.newLine();
@ -50,47 +56,31 @@ export class PythonLanguageGenerator implements LanguageGenerator {
`${pageAlias}.frame(${formatOptions({ name: actionInContext.frameName }, false)})` :
`${pageAlias}.frame(${formatOptions({ url: actionInContext.frameUrl }, 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;
}
const signals = toSignalMap(action);
if (dialogSignal)
formatter.add(` ${pageAlias}.once("dialog", lambda dialog: asyncio.create_task(dialog.dismiss()))`);
const waitForNavigation = navigationSignal && !performingAction;
const assertNavigation = navigationSignal && performingAction;
if (signals.dialog)
formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`);
const actionCall = this._generateActionCall(action);
let code = `${this._awaitPrefix}${subject}.${actionCall}`;
if (popupSignal) {
if (signals.popup) {
code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as popup_info {
${code}
}
${popupSignal.popupAlias} = popup_info.value`;
${signals.popup.popupAlias} = ${this._awaitPrefix}popup_info.value`;
}
if (downloadSignal) {
if (signals.download) {
code = `${this._asyncPrefix}with ${pageAlias}.expect_download() as download_info {
${code}
}
download = download_info.value`;
download = ${this._awaitPrefix}download_info.value`;
}
if (waitForNavigation) {
if (signals.waitForNavigation) {
code = `
# ${this._asyncPrefix}with ${pageAlias}.expect_navigation(url=${quote(navigationSignal!.url)}):
# ${this._asyncPrefix}with ${pageAlias}.expect_navigation(url=${quote(signals.waitForNavigation.url)}):
${this._asyncPrefix}with ${pageAlias}.expect_navigation() {
${code}
}`;
@ -98,8 +88,8 @@ export class PythonLanguageGenerator implements LanguageGenerator {
formatter.add(code);
if (assertNavigation)
formatter.add(` # assert ${pageAlias}.url == ${quote(navigationSignal!.url)}`);
if (signals.assertNavigation)
formatter.add(` # assert ${pageAlias}.url == ${quote(signals.assertNavigation.url)}`);
return formatter.format();
}
@ -131,7 +121,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
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)})`;
return `set_input_files(${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('+');
@ -140,11 +130,11 @@ export class PythonLanguageGenerator implements LanguageGenerator {
case 'navigate':
return `goto(${quote(action.url)})`;
case 'select':
return `selectOption(${quote(action.selector)}, ${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`;
return `select_option(${quote(action.selector)}, ${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`;
}
}
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string {
generateHeader(options: LanguageGeneratorOptions): string {
const formatter = new PythonFormatter();
if (this._isAsync) {
formatter.add(`
@ -152,15 +142,15 @@ import asyncio
from playwright.async_api import async_playwright
async def run(playwright) {
browser = await playwright.${browserName}.launch(${formatOptions(launchOptions, false)})
context = await browser.new_context(${formatContextOptions(contextOptions, deviceName)})`);
browser = await playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)})
context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`);
} else {
formatter.add(`
from playwright.sync_api import sync_playwright
def run(playwright) {
browser = playwright.${browserName}.launch(${formatOptions(launchOptions, false)})
context = browser.new_context(${formatContextOptions(contextOptions, deviceName)})`);
browser = playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)})
context = browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`);
}
return formatter.format();
}
@ -168,7 +158,7 @@ def run(playwright) {
generateFooter(saveStorage: string | undefined): string {
if (this._isAsync) {
const storageStateLine = saveStorage ? `\n await context.storage_state(path="${saveStorage}")` : '';
return ` # ---------------------${storageStateLine}
return `\n # ---------------------${storageStateLine}
await context.close()
await browser.close()
@ -178,7 +168,7 @@ async def main():
asyncio.run(main())`;
} else {
const storageStateLine = saveStorage ? `\n context.storage_state(path="${saveStorage}")` : '';
return ` # ---------------------${storageStateLine}
return `\n # ---------------------${storageStateLine}
context.close()
browser.close()

View file

@ -92,21 +92,25 @@ export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageActi
// Signals.
export type NavigationSignal = {
export type BaseSignal = {
isAsync?: boolean,
}
export type NavigationSignal = BaseSignal & {
name: 'navigation',
url: string,
};
export type PopupSignal = {
export type PopupSignal = BaseSignal & {
name: 'popup',
popupAlias: string,
};
export type DownloadSignal = {
export type DownloadSignal = BaseSignal & {
name: 'download',
};
export type DialogSignal = {
export type DialogSignal = BaseSignal & {
name: 'dialog',
dialogAlias: string,
};

View file

@ -136,7 +136,7 @@ export class RecorderApp extends EventEmitter {
// Testing harness for runCLI mode.
{
if (process.env.PWCLI_EXIT_FOR_TEST) {
if (process.env.PWCLI_EXIT_FOR_TEST && sources.length) {
process.stdout.write('\n-------------8<-------------\n');
process.stdout.write(sources[0].text);
process.stdout.write('\n-------------8<-------------\n');

View file

@ -22,13 +22,11 @@ import { describeFrame, toClickOptions, toModifiers } from './recorder/utils';
import { Page } from '../page';
import { Frame } from '../frames';
import { BrowserContext } from '../browserContext';
import { LanguageGenerator } from './recorder/language';
import { JavaScriptLanguageGenerator } from './recorder/javascript';
import { CSharpLanguageGenerator } from './recorder/csharp';
import { PythonLanguageGenerator } from './recorder/python';
import * as recorderSource from '../../generated/recorderSource';
import * as consoleApiSource from '../../generated/consoleApiSource';
import { BufferedOutput, FileOutput, OutputMultiplexer, RecorderOutput } from './recorder/outputs';
import { RecorderApp } from './recorder/recorderApp';
import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentation';
import { Point } from '../../common/types';
@ -47,14 +45,12 @@ export class RecorderSupplement {
private _timers = new Set<NodeJS.Timeout>();
private _context: BrowserContext;
private _mode: Mode;
private _output: OutputMultiplexer;
private _bufferedOutput: BufferedOutput;
private _recorderApp: RecorderApp | null = null;
private _params: channels.BrowserContextRecorderSupplementEnableParams;
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
private _pausedCallsMetadata = new Map<CallMetadata, () => void>();
private _pauseOnNextStatement = true;
private _recorderSource: Source;
private _recorderSources: Source[];
private _userSources = new Map<string, Source>();
static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> {
@ -75,32 +71,50 @@ export class RecorderSupplement {
this._context = context;
this._params = params;
this._mode = params.startRecording ? 'recording' : 'none';
let languageGenerator: LanguageGenerator;
let language = params.language || context._options.sdkLanguage;
switch (language) {
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break;
case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break;
case 'python':
case 'python-async': languageGenerator = new PythonLanguageGenerator(params.language === 'python-async'); break;
default: throw new Error(`Invalid target: '${params.language}'`);
}
if (language === 'python-async')
language = 'python';
const language = params.language || context._options.sdkLanguage;
this._recorderSource = { file: '<recorder>', text: '', language, highlight: [] };
this._bufferedOutput = new BufferedOutput(async text => {
this._recorderSource.text = text;
this._recorderSource.revealLine = text.split('\n').length - 1;
const languages = new Set([
new JavaScriptLanguageGenerator(),
new PythonLanguageGenerator(false),
new PythonLanguageGenerator(true),
new CSharpLanguageGenerator(),
]);
const primaryLanguage = [...languages].find(l => l.id === language)!;
if (!primaryLanguage)
throw new Error(`\n===============================\nInvalid target: '${params.language}'\n===============================\n`);
languages.delete(primaryLanguage);
const orderedLanguages = [primaryLanguage, ...languages];
this._recorderSources = [];
const generator = new CodeGenerator(context._browser.options.name, !!params.startRecording, params.launchOptions || {}, params.contextOptions || {}, params.device, params.saveStorage);
let text = '';
generator.on('change', () => {
this._recorderSources = [];
for (const languageGenerator of orderedLanguages) {
const source: Source = {
file: languageGenerator.fileName,
text: generator.generateText(languageGenerator),
language: languageGenerator.highlighter,
highlight: []
};
source.revealLine = source.text.split('\n').length - 1;
this._recorderSources.push(source);
if (languageGenerator === orderedLanguages[0])
text = source.text;
}
this._pushAllSources();
});
const outputs: RecorderOutput[] = [ this._bufferedOutput ];
if (params.outputFile)
outputs.push(new FileOutput(params.outputFile));
this._output = new OutputMultiplexer(outputs);
this._output.setEnabled(!!params.startRecording);
context.on(BrowserContext.Events.BeforeClose, () => this._output.flush());
const generator = new CodeGenerator(context._browser.options.name, !!params.startRecording, params.launchOptions || {}, params.contextOptions || {}, this._output, languageGenerator, params.device, params.saveStorage);
if (params.outputFile) {
context.on(BrowserContext.Events.BeforeClose, () => {
fs.writeFileSync(params.outputFile!, text);
text = '';
});
process.on('exit', () => {
if (text)
fs.writeFileSync(params.outputFile!, text);
});
}
this._generator = generator;
}
@ -114,7 +128,7 @@ export class RecorderSupplement {
if (data.event === 'setMode') {
this._mode = data.params.mode;
recorderApp.setMode(this._mode);
this._output.setEnabled(this._mode === 'recording');
this._generator.setEnabled(this._mode === 'recording');
if (this._mode !== 'none')
this._context.pages()[0].bringToFront().catch(() => {});
return;
@ -254,7 +268,6 @@ export class RecorderSupplement {
}
private _clearScript(): void {
this._bufferedOutput.clear();
this._generator.restart();
if (!!this._params.startRecording) {
for (const page of this._context.pages())
@ -376,7 +389,7 @@ export class RecorderSupplement {
}
private _pushAllSources() {
this._recorderApp?.setSources([this._recorderSource, ...this._userSources.values()]);
this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]);
}
async onBeforeInputAction(metadata: CallMetadata): Promise<void> {

View file

@ -29,7 +29,7 @@ declare global {
playwrightSetSources: (sources: Source[]) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
dispatch(data: any): Promise<void>;
playwrightSourceEchoForTest: string;
playwrightSourcesEchoForTest: Source[];
}
}
@ -38,20 +38,13 @@ export interface RecorderProps {
export const Recorder: React.FC<RecorderProps> = ({
}) => {
const [source, setSource] = React.useState<Source>({ file: '', language: 'javascript', text: '', highlight: [] });
const [sources, setSources] = React.useState<Source[]>([]);
const [paused, setPaused] = React.useState(false);
const [log, setLog] = React.useState(new Map<number, CallLog>());
const [mode, setMode] = React.useState<Mode>('none');
window.playwrightSetMode = setMode;
window.playwrightSetSources = sources => {
let s = sources.find(s => s.revealLine);
if (!s)
s = sources.find(s => s.file === source.file);
if (!s)
s = sources[0];
setSource(s);
};
window.playwrightSetSources = setSources;
window.playwrightSetPaused = setPaused;
window.playwrightUpdateLogs = callLogs => {
const newLog = new Map<number, CallLog>(log);
@ -60,7 +53,20 @@ export const Recorder: React.FC<RecorderProps> = ({
setLog(newLog);
};
window.playwrightSourceEchoForTest = source.text;
window.playwrightSourcesEchoForTest = sources;
const source = sources.find(source => {
let s = sources.find(s => s.revealLine);
if (!s)
s = sources.find(s => s.file === source.file);
if (!s)
s = sources[0];
return s;
}) || {
file: 'untitled',
text: '',
language: 'javascript',
highlight: []
};
const messagesEndRef = React.createRef<HTMLDivElement>();
React.useLayoutEffect(() => {

View file

@ -29,14 +29,28 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.hoverOverElement('button');
expect(selector).toBe('text=Submit');
const [message] = await Promise.all([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('click'),
recorder.waitForOutput('<javascript>', 'click'),
page.dispatchEvent('button', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=Submit
await page.click('text=Submit');`);
expect(sources.get('<python>').text).toContain(`
# Click text=Submit
page.click("text=Submit")`);
expect(sources.get('<async python>').text).toContain(`
# Click text=Submit
await page.click("text=Submit")`);
expect(sources.get('<csharp>').text).toContain(`
// Click text=Submit
await page.ClickAsync("text=Submit");`);
expect(message.text()).toBe('click');
});
@ -57,12 +71,13 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.hoverOverElement('button');
expect(selector).toBe('text=Submit');
const [message] = await Promise.all([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('click'),
recorder.waitForOutput('<javascript>', 'click'),
page.dispatchEvent('button', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=Submit
await page.click('text=Submit');`);
expect(message.text()).toBe('click');
@ -89,12 +104,12 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
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([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('click'),
recorder.waitForOutput('<javascript>', 'click'),
page.dispatchEvent('div', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=Some long text here
await page.click('text=Some long text here');`);
expect(message.text()).toBe('click');
@ -105,14 +120,28 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="name"]');
const [message] = await Promise.all([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('fill'),
recorder.waitForOutput('<javascript>', 'fill'),
page.fill('input', 'John')
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Fill input[name="name"]
await page.fill('input[name="name"]', 'John');`);
expect(sources.get('<python>').text).toContain(`
# Fill input[name="name"]
page.fill(\"input[name=\\\"name\\\"]\", \"John\")`);
expect(sources.get('<async python>').text).toContain(`
# Fill input[name="name"]
await page.fill(\"input[name=\\\"name\\\"]\", \"John\")`);
expect(sources.get('<csharp>').text).toContain(`
// Fill input[name="name"]
await page.FillAsync(\"input[name=\\\"name\\\"]\", \"John\");`);
expect(message.text()).toBe('John');
});
@ -121,12 +150,12 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.focusElement('textarea');
expect(selector).toBe('textarea[name="name"]');
const [message] = await Promise.all([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('fill'),
recorder.waitForOutput('<javascript>', 'fill'),
page.fill('textarea', 'John')
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Fill textarea[name="name"]
await page.fill('textarea[name="name"]', 'John');`);
expect(message.text()).toBe('John');
@ -140,14 +169,28 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const messages: any[] = [];
page.on('console', message => messages.push(message));
await Promise.all([
const [, sources] = await Promise.all([
recorder.waitForActionPerformed(),
recorder.waitForOutput('press'),
recorder.waitForOutput('<javascript>', 'press'),
page.press('input', 'Shift+Enter')
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Press Enter with modifiers
await page.press('input[name="name"]', 'Shift+Enter');`);
expect(sources.get('<python>').text).toContain(`
# Press Enter with modifiers
page.press(\"input[name=\\\"name\\\"]\", \"Shift+Enter\")`);
expect(sources.get('<async python>').text).toContain(`
# Press Enter with modifiers
await page.press(\"input[name=\\\"name\\\"]\", \"Shift+Enter\")`);
expect(sources.get('<csharp>').text).toContain(`
// Press Enter with modifiers
await page.PressAsync(\"input[name=\\\"name\\\"]\", \"Shift+Enter\");`);
expect(messages[0].text()).toBe('press');
});
@ -158,24 +201,25 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
`);
await page.click('input[name="one"]');
await recorder.waitForOutput('click');
await recorder.waitForOutput('<javascript>', 'click');
await page.keyboard.type('foobar123');
await recorder.waitForOutput('foobar123');
await recorder.waitForOutput('<javascript>', 'foobar123');
await page.keyboard.press('Tab');
await recorder.waitForOutput('Tab');
await recorder.waitForOutput('<javascript>', 'Tab');
await page.keyboard.type('barfoo321');
await recorder.waitForOutput('barfoo321');
await recorder.waitForOutput('<javascript>', 'barfoo321');
expect(recorder.output()).toContain(`
const text = recorder.sources().get('<javascript>').text;
expect(text).toContain(`
// Fill input[name="one"]
await page.fill('input[name="one"]', 'foobar123');`);
expect(recorder.output()).toContain(`
expect(text).toContain(`
// Press Tab
await page.press('input[name="one"]', 'Tab');`);
expect(recorder.output()).toContain(`
expect(text).toContain(`
// Fill input[name="two"]
await page.fill('input[name="two"]', 'barfoo321');`);
});
@ -190,12 +234,12 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
page.on('console', message => {
messages.push(message);
});
await Promise.all([
const [, sources] = await Promise.all([
recorder.waitForActionPerformed(),
recorder.waitForOutput('press'),
recorder.waitForOutput('<javascript>', 'press'),
page.press('input', 'ArrowDown')
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Press ArrowDown
await page.press('input[name="name"]', 'ArrowDown');`);
expect(messages[0].text()).toBe('press:ArrowDown');
@ -211,12 +255,12 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
page.on('console', message => {
messages.push(message);
});
await Promise.all([
const [, sources] = await Promise.all([
recorder.waitForActionPerformed(),
recorder.waitForOutput('press'),
recorder.waitForOutput('<javascript>', 'press'),
page.press('input', 'ArrowDown')
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Press ArrowDown
await page.press('input[name="name"]', 'ArrowDown');`);
expect(messages.length).toBe(2);
@ -230,14 +274,28 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="accept"]');
const [message] = await Promise.all([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('check'),
recorder.waitForOutput('<javascript>', 'check'),
page.click('input')
]);
await recorder.waitForOutput(`
expect(sources.get('<javascript>').text).toContain(`
// Check input[name="accept"]
await page.check('input[name="accept"]');`);
expect(sources.get('<python>').text).toContain(`
# Check input[name="accept"]
page.check(\"input[name=\\\"accept\\\"]\")`);
expect(sources.get('<async python>').text).toContain(`
# Check input[name="accept"]
await page.check(\"input[name=\\\"accept\\\"]\")`);
expect(sources.get('<csharp>').text).toContain(`
// Check input[name="accept"]
await page.CheckAsync(\"input[name=\\\"accept\\\"]\");`);
expect(message.text()).toBe('true');
});
@ -247,12 +305,13 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="accept"]');
const [message] = await Promise.all([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('check'),
recorder.waitForOutput('<javascript>', 'check'),
page.keyboard.press('Space')
]);
await recorder.waitForOutput(`
expect(sources.get('<javascript>').text).toContain(`
// Check input[name="accept"]
await page.check('input[name="accept"]');`);
expect(message.text()).toBe('true');
@ -264,14 +323,28 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.focusElement('input');
expect(selector).toBe('input[name="accept"]');
const [message] = await Promise.all([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('uncheck'),
recorder.waitForOutput('<javascript>', 'uncheck'),
page.click('input')
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Uncheck input[name="accept"]
await page.uncheck('input[name="accept"]');`);
expect(sources.get('<python>').text).toContain(`
# Uncheck input[name="accept"]
page.uncheck(\"input[name=\\\"accept\\\"]\")`);
expect(sources.get('<async python>').text).toContain(`
# Uncheck input[name="accept"]
await page.uncheck(\"input[name=\\\"accept\\\"]\")`);
expect(sources.get('<csharp>').text).toContain(`
// Uncheck input[name="accept"]
await page.UncheckAsync(\"input[name=\\\"accept\\\"]\");`);
expect(message.text()).toBe('false');
});
@ -281,14 +354,28 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.hoverOverElement('select');
expect(selector).toBe('select');
const [message] = await Promise.all([
const [message, sources] = await Promise.all([
page.waitForEvent('console'),
recorder.waitForOutput('select'),
recorder.waitForOutput('<javascript>', 'select'),
page.selectOption('select', '2')
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Select 2
await page.selectOption('select', '2');`);
expect(sources.get('<python>').text).toContain(`
# Select 2
page.select_option(\"select\", \"2\")`);
expect(sources.get('<async python>').text).toContain(`
# Select 2
await page.select_option(\"select\", \"2\")`);
expect(sources.get('<csharp>').text).toContain(`
// Select 2
await page.SelectOptionAsync(\"select\", \"2\");`);
expect(message.text()).toBe('2');
});
@ -300,17 +387,37 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.hoverOverElement('a');
expect(selector).toBe('text=link');
const [popup] = await Promise.all([
const [popup, sources] = await Promise.all([
page.context().waitForEvent('page'),
recorder.waitForOutput('waitForEvent'),
recorder.waitForOutput('<javascript>', 'waitForEvent'),
page.dispatchEvent('a', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=link
const [page1] = await Promise.all([
page.waitForEvent('popup'),
page.click('text=link')
]);`);
expect(sources.get('<python>').text).toContain(`
# Click text=link
with page.expect_popup() as popup_info:
page.click(\"text=link\")
page1 = popup_info.value`);
expect(sources.get('<async python>').text).toContain(`
# Click text=link
async with page.expect_popup() as popup_info:
await page.click(\"text=link\")
page1 = await popup_info.value`);
expect(sources.get('<csharp>').text).toContain(`
var page1Task = page.WaitForEventAsync(PageEvent.Popup)
await Task.WhenAll(
page1Task,
page.ClickAsync(\"text=link\"));`);
expect(popup.url()).toBe('about:blank');
});
@ -319,15 +426,32 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.hoverOverElement('a');
expect(selector).toBe('text=link');
await Promise.all([
const [, sources] = await Promise.all([
page.waitForNavigation(),
recorder.waitForOutput('assert'),
recorder.waitForOutput('<javascript>', 'assert'),
page.dispatchEvent('a', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=link
await page.click('text=link');
// assert.equal(page.url(), 'about:blank#foo');`);
expect(sources.get('<python>').text).toContain(`
# Click text=link
page.click(\"text=link\")
# assert page.url == \"about:blank#foo\"`);
expect(sources.get('<async python>').text).toContain(`
# Click text=link
await page.click(\"text=link\")
# assert page.url == \"about:blank#foo\"`);
expect(sources.get('<csharp>').text).toContain(`
// Click text=link
await page.ClickAsync(\"text=link\");
// Assert.Equal(\"about:blank#foo\", page.Url);`);
expect(page.url()).toContain('about:blank#foo');
});
@ -338,17 +462,37 @@ describe('cli codegen', (suite, { browserName, headful, mode }) => {
const selector = await recorder.hoverOverElement('a');
expect(selector).toBe('text=link');
await Promise.all([
const [, sources] = await Promise.all([
page.waitForNavigation(),
recorder.waitForOutput('waitForNavigation'),
recorder.waitForOutput('<javascript>', 'waitForNavigation'),
page.dispatchEvent('a', 'click', { detail: 1 })
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=link
await Promise.all([
page.waitForNavigation(/*{ url: 'about:blank#foo' }*/),
page.click('text=link')
]);`);
expect(sources.get('<python>').text).toContain(`
# Click text=link
# with page.expect_navigation(url=\"about:blank#foo\"):
with page.expect_navigation():
page.click(\"text=link\")`);
expect(sources.get('<async python>').text).toContain(`
# Click text=link
# async with page.expect_navigation(url=\"about:blank#foo\"):
async with page.expect_navigation():
await page.click(\"text=link\")`);
expect(sources.get('<csharp>').text).toContain(`
// Click text=link
await Task.WhenAll(
page.WaitForNavigationAsync(/*\"about:blank#foo\"*/),
page.ClickAsync(\"text=link\"));`);
expect(page.url()).toContain('about:blank#foo');
});
});

View file

@ -25,21 +25,64 @@ describe('cli codegen', (suite, { mode }) => {
}, () => {
it('should contain open page', async ({ recorder }) => {
await recorder.setContentAndWait(``);
await recorder.waitForOutput(`const page = await context.newPage();`);
const sources = await recorder.waitForOutput('<javascript>', `page.goto`);
expect(sources.get('<javascript>').text).toContain(`
// Open new page
const page = await context.newPage();`);
expect(sources.get('<python>').text).toContain(`
# Open new page
page = context.new_page()`);
expect(sources.get('<async python>').text).toContain(`
# Open new page
page = await context.new_page()`);
expect(sources.get('<csharp>').text).toContain(`
// Open new page
var page = await context.NewPageAsync();`);
});
it('should contain second page', async ({ context, recorder }) => {
await recorder.setContentAndWait(``);
await context.newPage();
await recorder.waitForOutput('page1');
expect(recorder.output()).toContain('const page1 = await context.newPage();');
const sources = await recorder.waitForOutput('<javascript>', 'page1');
expect(sources.get('<javascript>').text).toContain(`
// Open new page
const page1 = await context.newPage();`);
expect(sources.get('<python>').text).toContain(`
# Open new page
page1 = context.new_page()`);
expect(sources.get('<async python>').text).toContain(`
# Open new page
page1 = await context.new_page()`);
expect(sources.get('<csharp>').text).toContain(`
// Open new page
var page1 = await context.NewPageAsync();`);
});
it('should contain close page', async ({ context, recorder }) => {
await recorder.setContentAndWait(``);
await context.newPage();
await recorder.page.close();
await recorder.waitForOutput('page.close();');
const sources = await recorder.waitForOutput('<javascript>', 'page.close();');
expect(sources.get('<javascript>').text).toContain(`
await page.close();`);
expect(sources.get('<python>').text).toContain(`
page.close()`);
expect(sources.get('<async python>').text).toContain(`
await page.close()`);
expect(sources.get('<csharp>').text).toContain(`
await page.CloseAsync();`);
});
it('should not lead to an error if html gets clicked', async ({ context, recorder }) => {
@ -51,7 +94,7 @@ describe('cli codegen', (suite, { mode }) => {
const selector = await recorder.hoverOverElement('html');
expect(selector).toBe('html');
await recorder.page.close();
await recorder.waitForOutput('page.close();');
await recorder.waitForOutput('<javascript>', 'page.close();');
expect(errors.length).toBe(0);
});
@ -68,9 +111,24 @@ describe('cli codegen', (suite, { mode }) => {
await page.setInputFiles('input[type=file]', 'test/assets/file-to-upload.txt');
await page.click('input[type=file]');
await recorder.waitForOutput(`
const sources = await recorder.waitForOutput('<javascript>', 'setInputFiles');
expect(sources.get('<javascript>').text).toContain(`
// Upload file-to-upload.txt
await page.setInputFiles('input[type="file"]', 'file-to-upload.txt');`);
expect(sources.get('<python>').text).toContain(`
# Upload file-to-upload.txt
page.set_input_files(\"input[type=\\\"file\\\"]\", \"file-to-upload.txt\")`);
expect(sources.get('<async python>').text).toContain(`
# Upload file-to-upload.txt
await page.set_input_files(\"input[type=\\\"file\\\"]\", \"file-to-upload.txt\")`);
expect(sources.get('<csharp>').text).toContain(`
// Upload file-to-upload.txt
await page.SetInputFilesAsync(\"input[type=\\\"file\\\"]\", \"file-to-upload.txt\");`);
});
it('should upload multiple files', (test, { browserName }) => {
@ -86,9 +144,23 @@ describe('cli codegen', (suite, { mode }) => {
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(`
const sources = await recorder.waitForOutput('<javascript>', 'setInputFiles');
expect(sources.get('<javascript>').text).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']);`);
await page.setInputFiles('input[type=\"file\"]', ['file-to-upload.txt', 'file-to-upload-2.txt']);`);
expect(sources.get('<python>').text).toContain(`
# Upload file-to-upload.txt, file-to-upload-2.txt
page.set_input_files(\"input[type=\\\"file\\\"]\", [\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
expect(sources.get('<async python>').text).toContain(`
# Upload file-to-upload.txt, file-to-upload-2.txt
await page.set_input_files(\"input[type=\\\"file\\\"]\", [\"file-to-upload.txt\", \"file-to-upload-2.txt\"]`);
expect(sources.get('<csharp>').text).toContain(`
// Upload file-to-upload.txt, file-to-upload-2.txt
await page.SetInputFilesAsync(\"input[type=\\\"file\\\"]\", new[] { \"file-to-upload.txt\", \"file-to-upload-2.txt\" });`);
});
it('should clear files', (test, { browserName }) => {
@ -104,9 +176,24 @@ describe('cli codegen', (suite, { mode }) => {
await page.setInputFiles('input[type=file]', []);
await page.click('input[type=file]');
await recorder.waitForOutput(`
const sources = await recorder.waitForOutput('<javascript>', 'setInputFiles');
expect(sources.get('<javascript>').text).toContain(`
// Clear selected files
await page.setInputFiles('input[type="file"]', []);`);
await page.setInputFiles('input[type=\"file\"]', []);`);
expect(sources.get('<python>').text).toContain(`
# Clear selected files
page.set_input_files(\"input[type=\\\"file\\\"]\", []`);
expect(sources.get('<async python>').text).toContain(`
# Clear selected files
await page.set_input_files(\"input[type=\\\"file\\\"]\", []`);
expect(sources.get('<csharp>').text).toContain(`
// Clear selected files
await page.SetInputFilesAsync(\"input[type=\\\"file\\\"]\", new[] { });`);
});
it('should download files', async ({ page, recorder, httpServer }) => {
@ -129,12 +216,33 @@ describe('cli codegen', (suite, { mode }) => {
page.waitForEvent('download'),
page.click('text=Download')
]);
await recorder.waitForOutput(`
const sources = await recorder.waitForOutput('<javascript>', 'waitForEvent');
expect(sources.get('<javascript>').text).toContain(`
// Click text=Download
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('text=Download')
]);`);
expect(sources.get('<python>').text).toContain(`
# Click text=Download
with page.expect_download() as download_info:
page.click(\"text=Download\")
download = download_info.value`);
expect(sources.get('<async python>').text).toContain(`
# Click text=Download
async with page.expect_download() as download_info:
await page.click(\"text=Download\")
download = await download_info.value`);
expect(sources.get('<csharp>').text).toContain(`
// Click text=Download
var downloadTask = page.WaitForEventAsync(PageEvent.Download);
await Task.WhenAll(
downloadTask,
page.ClickAsync(\"text=Download\"));`);
});
it('should handle dialogs', async ({ page, recorder }) => {
@ -146,13 +254,38 @@ describe('cli codegen', (suite, { mode }) => {
await dialog.dismiss();
});
await page.click('text=click me');
await recorder.waitForOutput(`
const sources = await recorder.waitForOutput('<javascript>', 'once');
expect(sources.get('<javascript>').text).toContain(`
// Click text=click me
page.once('dialog', dialog => {
console.log(\`Dialog message: $\{dialog.message()}\`);
console.log(\`Dialog message: \${dialog.message()}\`);
dialog.dismiss().catch(() => {});
});
await page.click('text=click me')`);
await page.click('text=click me');`);
expect(sources.get('<python>').text).toContain(`
# Click text=click me
page.once(\"dialog\", lambda dialog: dialog.dismiss())
page.click(\"text=click me\")`);
expect(sources.get('<async python>').text).toContain(`
# Click text=click me
page.once(\"dialog\", lambda dialog: dialog.dismiss())
await page.click(\"text=click me\")`);
expect(sources.get('<csharp>').text).toContain(`
// Click text=click me
void page_Dialog1_EventHandler(object sender, DialogEventArgs e)
{
Console.WriteLine($\"Dialog message: {e.Dialog.Message}\");
e.Dialog.DismissAsync();
page.Dialog -= page_Dialog1_EventHandler;
}
page.Dialog += page_Dialog1_EventHandler;
await page.ClickAsync(\"text=click me\");`);
});
it('should handle history.postData', async ({ page, recorder, httpServer }) => {
@ -169,7 +302,7 @@ describe('cli codegen', (suite, { mode }) => {
</script>`, httpServer.PREFIX);
for (let i = 1; i < 3; ++i) {
await page.evaluate('pushState()');
await recorder.waitForOutput(`await page.goto('${httpServer.PREFIX}/#seqNum=${i}');`);
await recorder.waitForOutput('<javascript>', `await page.goto('${httpServer.PREFIX}/#seqNum=${i}');`);
}
});
@ -182,14 +315,15 @@ describe('cli codegen', (suite, { mode }) => {
expect(selector).toBe('text=link');
await page.click('a', { modifiers: [ platform === 'darwin' ? 'Meta' : 'Control'] });
await recorder.waitForOutput('page1');
const sources = await recorder.waitForOutput('<javascript>', 'page1');
if (browserName === 'chromium') {
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Open new page
const page1 = await context.newPage();
page1.goto('about:blank?foo');`);
} else if (browserName === 'firefox') {
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=link
const [page1] = await Promise.all([
page.waitForEvent('popup'),
@ -216,13 +350,23 @@ describe('cli codegen', (suite, { mode }) => {
await recorder.setPageContentAndWait(popup2, '<input id=name>');
await popup1.type('input', 'TextA');
await recorder.waitForOutput('TextA');
await recorder.waitForOutput('<javascript>', 'TextA');
await popup2.type('input', 'TextB');
await recorder.waitForOutput('TextB');
await recorder.waitForOutput('<javascript>', 'TextB');
expect(recorder.output()).toContain(`await page1.fill('input', 'TextA');`);
expect(recorder.output()).toContain(`await page2.fill('input', 'TextB');`);
const sources = recorder.sources();
expect(sources.get('<javascript>').text).toContain(`await page1.fill('input', 'TextA');`);
expect(sources.get('<javascript>').text).toContain(`await page2.fill('input', 'TextB');`);
expect(sources.get('<python>').text).toContain(`page1.fill(\"input\", \"TextA\")`);
expect(sources.get('<python>').text).toContain(`page2.fill(\"input\", \"TextB\")`);
expect(sources.get('<async python>').text).toContain(`await page1.fill(\"input\", \"TextA\")`);
expect(sources.get('<async python>').text).toContain(`await page2.fill(\"input\", \"TextB\")`);
expect(sources.get('<csharp>').text).toContain(`await page1.FillAsync(\"input\", \"TextA\");`);
expect(sources.get('<csharp>').text).toContain(`await page2.FillAsync(\"input\", \"TextB\");`);
});
it('click should emit events in order', async ({ page, recorder }) => {
@ -239,7 +383,7 @@ describe('cli codegen', (suite, { mode }) => {
page.on('console', message => messages.push(message.text()));
await Promise.all([
page.click('button'),
recorder.waitForOutput('page.click')
recorder.waitForOutput('<javascript>', 'page.click')
]);
expect(messages).toEqual(['mousedown', 'mouseup', 'click']);
});
@ -283,35 +427,74 @@ describe('cli codegen', (suite, { mode }) => {
const frameTwo = page.frame({ name: 'two' });
const otherFrame = page.frames().find(f => f !== page.mainFrame() && !f.name());
await Promise.all([
recorder.waitForOutput('one'),
let [sources] = await Promise.all([
recorder.waitForOutput('<javascript>', 'one'),
frameOne.click('div'),
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=Hi, I'm frame
await page.frame({
name: 'one'
}).click('text=Hi, I\\'m frame');`);
await Promise.all([
recorder.waitForOutput('two'),
expect(sources.get('<python>').text).toContain(`
# Click text=Hi, I'm frame
page.frame(name=\"one\").click(\"text=Hi, I'm frame\")`);
expect(sources.get('<async python>').text).toContain(`
# Click text=Hi, I'm frame
await page.frame(name=\"one\").click(\"text=Hi, I'm frame\")`);
expect(sources.get('<csharp>').text).toContain(`
// Click text=Hi, I'm frame
await page.GetFrame(name: \"one\").ClickAsync(\"text=Hi, I'm frame\");`);
[sources] = await Promise.all([
recorder.waitForOutput('<javascript>', 'two'),
frameTwo.click('div'),
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=Hi, I'm frame
await page.frame({
name: 'two'
}).click('text=Hi, I\\'m frame');`);
await Promise.all([
recorder.waitForOutput('url: \''),
expect(sources.get('<python>').text).toContain(`
# Click text=Hi, I'm frame
page.frame(name=\"two\").click(\"text=Hi, I'm frame\")`);
expect(sources.get('<async python>').text).toContain(`
# Click text=Hi, I'm frame
await page.frame(name=\"two\").click(\"text=Hi, I'm frame\")`);
expect(sources.get('<csharp>').text).toContain(`
// Click text=Hi, I'm frame
await page.GetFrame(name: \"two\").ClickAsync(\"text=Hi, I'm frame\");`);
[sources] = await Promise.all([
recorder.waitForOutput('<javascript>', 'url: \''),
otherFrame.click('div'),
]);
expect(recorder.output()).toContain(`
expect(sources.get('<javascript>').text).toContain(`
// Click text=Hi, I'm frame
await page.frame({
url: '${otherFrame.url()}'
url: 'http://localhost:${server.PORT}/frames/frame.html'
}).click('text=Hi, I\\'m frame');`);
expect(sources.get('<python>').text).toContain(`
# Click text=Hi, I'm frame
page.frame(url=\"http://localhost:${server.PORT}/frames/frame.html\").click(\"text=Hi, I'm frame\")`);
expect(sources.get('<async python>').text).toContain(`
# Click text=Hi, I'm frame
await page.frame(url=\"http://localhost:${server.PORT}/frames/frame.html\").click(\"text=Hi, I'm frame\")`);
expect(sources.get('<csharp>').text).toContain(`
// Click text=Hi, I'm frame
await page.GetFrame(url: \"http://localhost:${server.PORT}/frames/frame.html\").ClickAsync(\"text=Hi, I'm frame\");`);
});
it('should record navigations after identical pushState', async ({ page, recorder, httpServer }) => {
@ -329,6 +512,6 @@ describe('cli codegen', (suite, { mode }) => {
await page.evaluate('pushState()');
await page.goto(httpServer.PREFIX + '/page2.html');
await recorder.waitForOutput(`await page.goto('${httpServer.PREFIX}/page2.html');`);
await recorder.waitForOutput('<javascript>', `await page.goto('${httpServer.PREFIX}/page2.html');`);
});
});

View file

@ -20,6 +20,7 @@ import { ChildProcess, spawn } from 'child_process';
import { folio as baseFolio } from '../recorder.fixtures';
import type { BrowserType, Browser, Page } from '../..';
export { config } from 'folio';
import type { Source } from '../../src/server/supplements/recorder/recorderTypes';
type WorkerFixtures = {
browserType: BrowserType<Browser>;
@ -66,7 +67,7 @@ class Recorder {
_actionReporterInstalled: boolean
_actionPerformedCallback: Function
recorderPage: Page;
private _text: string = '';
private _sources = new Map<string, Source>();
constructor(page: Page, recorderPage: Page) {
this.page = page;
@ -97,24 +98,26 @@ class Recorder {
]);
}
async waitForOutput(text: string): Promise<void> {
this._text = await this.recorderPage.evaluate((text: string) => {
async waitForOutput(file: string, text: string): Promise<Map<string, Source>> {
const sources: Source[] = await this.recorderPage.evaluate((params: { text: string, file: string }) => {
const w = window as any;
return new Promise(f => {
const poll = () => {
if (w.playwrightSourceEchoForTest && w.playwrightSourceEchoForTest.includes(text)) {
f(w.playwrightSourceEchoForTest);
return;
}
const source = (w.playwrightSourcesEchoForTest || []).find((s: Source) => s.file === params.file);
if (source && source.text.includes(params.text))
f(w.playwrightSourcesEchoForTest);
setTimeout(poll, 300);
};
setTimeout(poll);
poll();
});
}, text);
}, { text, file });
for (const source of sources)
this._sources.set(source.file, source);
return this._sources;
}
output(): string {
return this._text;
sources(): Map<string, Source> {
return this._sources;
}
async waitForHighlight(action: () => Promise<void>): Promise<string> {