diff --git a/src/server/supplements/recorder/codeGenerator.ts b/src/server/supplements/recorder/codeGenerator.ts index 31872c2ed9..dd86e78bd8 100644 --- a/src/server/supplements/recorder/codeGenerator.ts +++ b/src/server/supplements/recorder/codeGenerator.ts @@ -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'); + } } diff --git a/src/server/supplements/recorder/csharp.ts b/src/server/supplements/recorder/csharp.ts index 23ed7683f9..bf2c6ed71e 100644 --- a/src/server/supplements/recorder/csharp.ts +++ b/src/server/supplements/recorder/csharp.ts @@ -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 = ''; + 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}`; } } diff --git a/src/server/supplements/recorder/javascript.ts b/src/server/supplements/recorder/javascript.ts index 49021582e5..24fab75f58 100644 --- a/src/server/supplements/recorder/javascript.ts +++ b/src/server/supplements/recorder/javascript.ts @@ -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 = ''; + 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(); })();`; diff --git a/src/server/supplements/recorder/language.ts b/src/server/supplements/recorder/language.ts index b5a57a7008..7160484d7d 100644 --- a/src/server/supplements/recorder/language.ts +++ b/src/server/supplements/recorder/language.ts @@ -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, + }; +} diff --git a/src/server/supplements/recorder/outputs.ts b/src/server/supplements/recorder/outputs.ts deleted file mode 100644 index 693266bbc5..0000000000 --- a/src/server/supplements/recorder/outputs.ts +++ /dev/null @@ -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()); - } -} diff --git a/src/server/supplements/recorder/python.ts b/src/server/supplements/recorder/python.ts index 9d4bc949df..9631683ef5 100644 --- a/src/server/supplements/recorder/python.ts +++ b/src/server/supplements/recorder/python.ts @@ -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 = ''; + highlighter = 'python'; + private _awaitPrefix: '' | 'await '; private _asyncPrefix: '' | 'async '; private _isAsync: boolean; constructor(isAsync: boolean) { + this.id = isAsync ? 'python-async' : 'python'; + this.fileName = isAsync ? '' : ''; 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() diff --git a/src/server/supplements/recorder/recorderActions.ts b/src/server/supplements/recorder/recorderActions.ts index b4c1819d10..a430763961 100644 --- a/src/server/supplements/recorder/recorderActions.ts +++ b/src/server/supplements/recorder/recorderActions.ts @@ -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, }; diff --git a/src/server/supplements/recorder/recorderApp.ts b/src/server/supplements/recorder/recorderApp.ts index a8e0eacd8e..4d95ba043c 100644 --- a/src/server/supplements/recorder/recorderApp.ts +++ b/src/server/supplements/recorder/recorderApp.ts @@ -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'); diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index 8c94d6db24..7fcbcddd4d 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -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(); 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(); private _pausedCallsMetadata = new Map void>(); private _pauseOnNextStatement = true; - private _recorderSource: Source; + private _recorderSources: Source[]; private _userSources = new Map(); static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { @@ -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: '', 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 { diff --git a/src/web/recorder/recorder.tsx b/src/web/recorder/recorder.tsx index 5783bb0d4e..cb9341d9dd 100644 --- a/src/web/recorder/recorder.tsx +++ b/src/web/recorder/recorder.tsx @@ -29,7 +29,7 @@ declare global { playwrightSetSources: (sources: Source[]) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void; dispatch(data: any): Promise; - playwrightSourceEchoForTest: string; + playwrightSourcesEchoForTest: Source[]; } } @@ -38,20 +38,13 @@ export interface RecorderProps { export const Recorder: React.FC = ({ }) => { - const [source, setSource] = React.useState({ file: '', language: 'javascript', text: '', highlight: [] }); + const [sources, setSources] = React.useState([]); const [paused, setPaused] = React.useState(false); const [log, setLog] = React.useState(new Map()); const [mode, setMode] = React.useState('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(log); @@ -60,7 +53,20 @@ export const Recorder: React.FC = ({ 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(); React.useLayoutEffect(() => { diff --git a/test/cli/cli-codegen-1.spec.ts b/test/cli/cli-codegen-1.spec.ts index 088ebe5c63..9f1fa1f8d7 100644 --- a/test/cli/cli-codegen-1.spec.ts +++ b/test/cli/cli-codegen-1.spec.ts @@ -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('', 'click'), page.dispatchEvent('button', 'click', { detail: 1 }) ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').text).toContain(` // Click text=Submit await page.click('text=Submit');`); + + expect(sources.get('').text).toContain(` + # Click text=Submit + page.click("text=Submit")`); + + expect(sources.get('').text).toContain(` + # Click text=Submit + await page.click("text=Submit")`); + + expect(sources.get('').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('', 'click'), page.dispatchEvent('button', 'click', { detail: 1 }) ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').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(`
Some long text here
`); - const [message] = await Promise.all([ + const [message, sources] = await Promise.all([ page.waitForEvent('console'), - recorder.waitForOutput('click'), + recorder.waitForOutput('', 'click'), page.dispatchEvent('div', 'click', { detail: 1 }) ]); - expect(recorder.output()).toContain(` + expect(sources.get('').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('', 'fill'), page.fill('input', 'John') ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').text).toContain(` // Fill input[name="name"] await page.fill('input[name="name"]', 'John');`); + + expect(sources.get('').text).toContain(` + # Fill input[name="name"] + page.fill(\"input[name=\\\"name\\\"]\", \"John\")`); + + expect(sources.get('').text).toContain(` + # Fill input[name="name"] + await page.fill(\"input[name=\\\"name\\\"]\", \"John\")`); + + expect(sources.get('').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('', 'fill'), page.fill('textarea', 'John') ]); - expect(recorder.output()).toContain(` + expect(sources.get('').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('', 'press'), page.press('input', 'Shift+Enter') ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').text).toContain(` // Press Enter with modifiers await page.press('input[name="name"]', 'Shift+Enter');`); + + expect(sources.get('').text).toContain(` + # Press Enter with modifiers + page.press(\"input[name=\\\"name\\\"]\", \"Shift+Enter\")`); + + expect(sources.get('').text).toContain(` + # Press Enter with modifiers + await page.press(\"input[name=\\\"name\\\"]\", \"Shift+Enter\")`); + + expect(sources.get('').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('', 'click'); await page.keyboard.type('foobar123'); - await recorder.waitForOutput('foobar123'); + await recorder.waitForOutput('', 'foobar123'); await page.keyboard.press('Tab'); - await recorder.waitForOutput('Tab'); + await recorder.waitForOutput('', 'Tab'); await page.keyboard.type('barfoo321'); - await recorder.waitForOutput('barfoo321'); + await recorder.waitForOutput('', 'barfoo321'); - expect(recorder.output()).toContain(` + const text = recorder.sources().get('').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('', 'press'), page.press('input', 'ArrowDown') ]); - expect(recorder.output()).toContain(` + expect(sources.get('').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('', 'press'), page.press('input', 'ArrowDown') ]); - expect(recorder.output()).toContain(` + expect(sources.get('').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('', 'check'), page.click('input') ]); - await recorder.waitForOutput(` + + expect(sources.get('').text).toContain(` // Check input[name="accept"] await page.check('input[name="accept"]');`); + + expect(sources.get('').text).toContain(` + # Check input[name="accept"] + page.check(\"input[name=\\\"accept\\\"]\")`); + + expect(sources.get('').text).toContain(` + # Check input[name="accept"] + await page.check(\"input[name=\\\"accept\\\"]\")`); + + expect(sources.get('').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('', 'check'), page.keyboard.press('Space') ]); - await recorder.waitForOutput(` + + expect(sources.get('').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('', 'uncheck'), page.click('input') ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').text).toContain(` // Uncheck input[name="accept"] await page.uncheck('input[name="accept"]');`); + + expect(sources.get('').text).toContain(` + # Uncheck input[name="accept"] + page.uncheck(\"input[name=\\\"accept\\\"]\")`); + + expect(sources.get('').text).toContain(` + # Uncheck input[name="accept"] + await page.uncheck(\"input[name=\\\"accept\\\"]\")`); + + expect(sources.get('').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('', 'select'), page.selectOption('select', '2') ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').text).toContain(` // Select 2 await page.selectOption('select', '2');`); + + expect(sources.get('').text).toContain(` + # Select 2 + page.select_option(\"select\", \"2\")`); + + expect(sources.get('').text).toContain(` + # Select 2 + await page.select_option(\"select\", \"2\")`); + + expect(sources.get('').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('', 'waitForEvent'), page.dispatchEvent('a', 'click', { detail: 1 }) ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').text).toContain(` // Click text=link const [page1] = await Promise.all([ page.waitForEvent('popup'), page.click('text=link') ]);`); + + expect(sources.get('').text).toContain(` + # Click text=link + with page.expect_popup() as popup_info: + page.click(\"text=link\") + page1 = popup_info.value`); + + expect(sources.get('').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('').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('', 'assert'), page.dispatchEvent('a', 'click', { detail: 1 }) ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').text).toContain(` // Click text=link await page.click('text=link'); // assert.equal(page.url(), 'about:blank#foo');`); + + expect(sources.get('').text).toContain(` + # Click text=link + page.click(\"text=link\") + # assert page.url == \"about:blank#foo\"`); + + expect(sources.get('').text).toContain(` + # Click text=link + await page.click(\"text=link\") + # assert page.url == \"about:blank#foo\"`); + + expect(sources.get('').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('', 'waitForNavigation'), page.dispatchEvent('a', 'click', { detail: 1 }) ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').text).toContain(` // Click text=link await Promise.all([ page.waitForNavigation(/*{ url: 'about:blank#foo' }*/), page.click('text=link') ]);`); + + expect(sources.get('').text).toContain(` + # Click text=link + # with page.expect_navigation(url=\"about:blank#foo\"): + with page.expect_navigation(): + page.click(\"text=link\")`); + + expect(sources.get('').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('').text).toContain(` +// Click text=link +await Task.WhenAll( + page.WaitForNavigationAsync(/*\"about:blank#foo\"*/), + page.ClickAsync(\"text=link\"));`); + expect(page.url()).toContain('about:blank#foo'); }); }); diff --git a/test/cli/cli-codegen-2.spec.ts b/test/cli/cli-codegen-2.spec.ts index b4e90eb6a7..daef698566 100644 --- a/test/cli/cli-codegen-2.spec.ts +++ b/test/cli/cli-codegen-2.spec.ts @@ -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('', `page.goto`); + + expect(sources.get('').text).toContain(` + // Open new page + const page = await context.newPage();`); + + expect(sources.get('').text).toContain(` + # Open new page + page = context.new_page()`); + + expect(sources.get('').text).toContain(` + # Open new page + page = await context.new_page()`); + + expect(sources.get('').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('', 'page1'); + + expect(sources.get('').text).toContain(` + // Open new page + const page1 = await context.newPage();`); + + expect(sources.get('').text).toContain(` + # Open new page + page1 = context.new_page()`); + + expect(sources.get('').text).toContain(` + # Open new page + page1 = await context.new_page()`); + + expect(sources.get('').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('', 'page.close();'); + + expect(sources.get('').text).toContain(` + await page.close();`); + + expect(sources.get('').text).toContain(` + page.close()`); + + expect(sources.get('').text).toContain(` + await page.close()`); + + expect(sources.get('').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('', '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('', 'setInputFiles'); + + expect(sources.get('').text).toContain(` // Upload file-to-upload.txt await page.setInputFiles('input[type="file"]', 'file-to-upload.txt');`); + + expect(sources.get('').text).toContain(` + # Upload file-to-upload.txt + page.set_input_files(\"input[type=\\\"file\\\"]\", \"file-to-upload.txt\")`); + + expect(sources.get('').text).toContain(` + # Upload file-to-upload.txt + await page.set_input_files(\"input[type=\\\"file\\\"]\", \"file-to-upload.txt\")`); + + expect(sources.get('').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('', 'setInputFiles'); + + expect(sources.get('').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('').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('').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('').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('', 'setInputFiles'); + + expect(sources.get('').text).toContain(` // Clear selected files - await page.setInputFiles('input[type="file"]', []);`); + await page.setInputFiles('input[type=\"file\"]', []);`); + + expect(sources.get('').text).toContain(` + # Clear selected files + page.set_input_files(\"input[type=\\\"file\\\"]\", []`); + + expect(sources.get('').text).toContain(` + # Clear selected files + await page.set_input_files(\"input[type=\\\"file\\\"]\", []`); + + expect(sources.get('').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('', 'waitForEvent'); + + expect(sources.get('').text).toContain(` // Click text=Download const [download] = await Promise.all([ page.waitForEvent('download'), page.click('text=Download') ]);`); + + expect(sources.get('').text).toContain(` + # Click text=Download + with page.expect_download() as download_info: + page.click(\"text=Download\") + download = download_info.value`); + + expect(sources.get('').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('').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('', 'once'); + + expect(sources.get('').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('').text).toContain(` + # Click text=click me + page.once(\"dialog\", lambda dialog: dialog.dismiss()) + page.click(\"text=click me\")`); + + expect(sources.get('').text).toContain(` + # Click text=click me + page.once(\"dialog\", lambda dialog: dialog.dismiss()) + await page.click(\"text=click me\")`); + + expect(sources.get('').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 }) => { `, 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('', `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('', 'page1'); + if (browserName === 'chromium') { - expect(recorder.output()).toContain(` + expect(sources.get('').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('').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, ''); await popup1.type('input', 'TextA'); - await recorder.waitForOutput('TextA'); + await recorder.waitForOutput('', 'TextA'); await popup2.type('input', 'TextB'); - await recorder.waitForOutput('TextB'); + await recorder.waitForOutput('', '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('').text).toContain(`await page1.fill('input', 'TextA');`); + expect(sources.get('').text).toContain(`await page2.fill('input', 'TextB');`); + + expect(sources.get('').text).toContain(`page1.fill(\"input\", \"TextA\")`); + expect(sources.get('').text).toContain(`page2.fill(\"input\", \"TextB\")`); + + expect(sources.get('').text).toContain(`await page1.fill(\"input\", \"TextA\")`); + expect(sources.get('').text).toContain(`await page2.fill(\"input\", \"TextB\")`); + + expect(sources.get('').text).toContain(`await page1.FillAsync(\"input\", \"TextA\");`); + expect(sources.get('').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('', '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('', 'one'), frameOne.click('div'), ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').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('').text).toContain(` + # Click text=Hi, I'm frame + page.frame(name=\"one\").click(\"text=Hi, I'm frame\")`); + + expect(sources.get('').text).toContain(` + # Click text=Hi, I'm frame + await page.frame(name=\"one\").click(\"text=Hi, I'm frame\")`); + + expect(sources.get('').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('', 'two'), frameTwo.click('div'), ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').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('').text).toContain(` + # Click text=Hi, I'm frame + page.frame(name=\"two\").click(\"text=Hi, I'm frame\")`); + + expect(sources.get('').text).toContain(` + # Click text=Hi, I'm frame + await page.frame(name=\"two\").click(\"text=Hi, I'm frame\")`); + + expect(sources.get('').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('', 'url: \''), otherFrame.click('div'), ]); - expect(recorder.output()).toContain(` + + expect(sources.get('').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('').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('').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('').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('', `await page.goto('${httpServer.PREFIX}/page2.html');`); }); }); diff --git a/test/cli/cli.fixtures.ts b/test/cli/cli.fixtures.ts index 5d4be1d699..891140b811 100644 --- a/test/cli/cli.fixtures.ts +++ b/test/cli/cli.fixtures.ts @@ -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; @@ -66,7 +67,7 @@ class Recorder { _actionReporterInstalled: boolean _actionPerformedCallback: Function recorderPage: Page; - private _text: string = ''; + private _sources = new Map(); constructor(page: Page, recorderPage: Page) { this.page = page; @@ -97,24 +98,26 @@ class Recorder { ]); } - async waitForOutput(text: string): Promise { - this._text = await this.recorderPage.evaluate((text: string) => { + async waitForOutput(file: string, text: string): Promise> { + 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 { + return this._sources; } async waitForHighlight(action: () => Promise): Promise {