diff --git a/package-lock.json b/package-lock.json index d733fb470e..298593d44e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4042,6 +4042,11 @@ "minimalistic-assert": "^1.0.1" } }, + "highlight.js": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz", + "integrity": "sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg==" + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", diff --git a/package.json b/package.json index 4ef0e6880e..93e1804108 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "commander": "^6.1.0", "debug": "^4.1.1", "extract-zip": "^2.0.1", + "highlight.js": "^10.1.2", "https-proxy-agent": "^5.0.0", "jpeg-js": "^0.4.2", "mime": "^2.4.6", diff --git a/packages/installation-tests/installation-tests.sh b/packages/installation-tests/installation-tests.sh index 06a3d8381c..177aa96488 100755 --- a/packages/installation-tests/installation-tests.sh +++ b/packages/installation-tests/installation-tests.sh @@ -54,8 +54,9 @@ function run_tests { test_playwright_electron_should_work test_electron_types test_android_types - test_playwright_cli_should_work + test_playwright_cli_screenshot_should_work test_playwright_cli_install_should_work + test_playwright_cli_codegen_should_work } function test_screencast { @@ -332,7 +333,7 @@ function test_android_types { echo "${FUNCNAME[0]} success" } -function test_playwright_cli_should_work { +function test_playwright_cli_screenshot_should_work { initialize_test "${FUNCNAME[0]}" npm install ${PLAYWRIGHT_TGZ} @@ -375,6 +376,36 @@ function test_playwright_cli_install_should_work { echo "${FUNCNAME[0]} success" } +function test_playwright_cli_codegen_should_work { + initialize_test "${FUNCNAME[0]}" + + npm install ${PLAYWRIGHT_TGZ} + + echo "Running playwright codegen" + OUTPUT=$(PWCLI_EXIT_FOR_TEST=1 xvfb-run --auto-servernum -- bash -c "npx playwright codegen") + if [[ "${OUTPUT}" != *"chromium.launch"* ]]; then + echo "ERROR: missing chromium.launch in the output" + exit 1 + fi + if [[ "${OUTPUT}" != *"browser.close"* ]]; then + echo "ERROR: missing browser.close in the output" + exit 1 + fi + + echo "Running playwright codegen --target=python" + OUTPUT=$(PWCLI_EXIT_FOR_TEST=1 xvfb-run --auto-servernum -- bash -c "npx playwright codegen --target=python") + if [[ "${OUTPUT}" != *"chromium.launch"* ]]; then + echo "ERROR: missing chromium.launch in the output" + exit 1 + fi + if [[ "${OUTPUT}" != *"browser.close"* ]]; then + echo "ERROR: missing browser.close in the output" + exit 1 + fi + + echo "${FUNCNAME[0]} success" +} + function initialize_test { cd ${TEST_ROOT} local TEST_NAME="./$1" diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 5df78587bb..76a643cb43 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -24,13 +24,14 @@ import * as os from 'os'; import * as fs from 'fs'; import { installBrowsersWithProgressBar } from '../install/installer'; import * as consoleApiSource from '../generated/consoleApiSource'; - -// TODO: we can import from '../..' instead, but that requires generating types -// before build, and currently type generator depends on the build. -import type { Browser, BrowserContext, Page, BrowserType } from '../client/api'; -import type { Playwright } from '../client/playwright'; -import type { BrowserContextOptions, LaunchOptions } from '../client/types'; -const playwright = require('../inprocess') as Playwright; +import { OutputMultiplexer, TerminalOutput, FileOutput } from './codegen/outputs'; +import { CodeGenerator, CodeGeneratorOutput } from './codegen/codeGenerator'; +import { JavaScriptLanguageGenerator, LanguageGenerator } from './codegen/languages'; +import { PythonLanguageGenerator } from './codegen/languages/python'; +import { CSharpLanguageGenerator } from './codegen/languages/csharp'; +import { RecorderController } from './codegen/recorderController'; +import type { Browser, BrowserContext, Page, BrowserType, BrowserContextOptions, LaunchOptions } from '../..'; +import * as playwright from '../..'; program .version('Version ' + require('../../package.json').version) @@ -80,6 +81,22 @@ for (const {alias, name, type} of browsers) { }); } +program + .command('codegen [url]') + .description('open page and generate code for user actions') + .option('-o, --output ', 'saves the generated script to a file') + .option('--target ', `language to use, one of javascript, python, python-async, csharp`, process.env.PW_CLI_TARGET_LANG || 'javascript') + .action(function(url, command) { + codegen(command.parent, url, command.target, command.output); + }).on('--help', function() { + console.log(''); + console.log('Examples:'); + console.log(''); + console.log(' $ codegen'); + console.log(' $ codegen --target=python'); + console.log(' $ -b webkit codegen https://example.com'); + }); + program .command('screenshot ') .description('capture a page screenshot') @@ -285,7 +302,36 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi async function open(options: Options, url: string | undefined) { const { context } = await launchContext(options, false); - context._extendInjectedScript(consoleApiSource.source); + (context as any)._extendInjectedScript(consoleApiSource.source); + await openPage(context, url); + if (process.env.PWCLI_EXIT_FOR_TEST) + await Promise.all(context.pages().map(p => p.close())); +} + +async function codegen(options: Options, url: string | undefined, target: string, outputFile?: string) { + let languageGenerator: LanguageGenerator; + + switch (target) { + case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break; + case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break; + case 'python': + case 'python-async': languageGenerator = new PythonLanguageGenerator(target === 'python-async'); break; + default: throw new Error(`Invalid target: '${target}'`); + } + + const { context, browserName, launchOptions, contextOptions } = await launchContext(options, false); + + if (process.env.PWTRACE) + contextOptions.recordVideo = { dir: path.join(process.cwd(), '.trace') }; + + const outputs: CodeGeneratorOutput[] = [new TerminalOutput(process.stdout, languageGenerator.highligherType())]; + if (outputFile) + outputs.push(new FileOutput(outputFile)); + const output = new OutputMultiplexer(outputs); + + const generator = new CodeGenerator(browserName, launchOptions, contextOptions, output, languageGenerator, options.device, options.saveStorage); + new RecorderController(context, generator); + (context as any)._extendInjectedScript(consoleApiSource.source); await openPage(context, url); if (process.env.PWCLI_EXIT_FOR_TEST) await Promise.all(context.pages().map(p => p.close())); @@ -326,7 +372,7 @@ async function pdf(options: Options, captureOptions: CaptureOptions, url: string await browser.close(); } -function lookupBrowserType(options: Options): BrowserType { +function lookupBrowserType(options: Options): BrowserType { let name = options.browser; if (options.device) { const device = playwright.devices[options.device]; diff --git a/src/cli/codegen/codeGenerator.ts b/src/cli/codegen/codeGenerator.ts new file mode 100644 index 0000000000..e205859208 --- /dev/null +++ b/src/cli/codegen/codeGenerator.ts @@ -0,0 +1,137 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { LaunchOptions, Frame, BrowserContextOptions } from '../../..'; +import { LanguageGenerator } from './languages'; +import { Action, Signal } from './recorderActions'; + +export type ActionInContext = { + pageAlias: string; + frame: Frame; + action: Action; + committed?: boolean; +} + +export interface CodeGeneratorOutput { + printLn(text: string): void; + popLn(text: string): void; + flush(): void; +} + +export class CodeGenerator { + private _currentAction: ActionInContext | undefined; + private _lastAction: ActionInContext | undefined; + private _lastActionText: string | undefined; + private _languageGenerator: LanguageGenerator; + private _output: CodeGeneratorOutput; + private _footerText: string; + + constructor(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, deviceName: string | undefined, saveStorage: string | undefined) { + this._output = output; + this._languageGenerator = languageGenerator; + + launchOptions = { headless: false, ...launchOptions }; + const header = this._languageGenerator.generateHeader(browserName, launchOptions, contextOptions, deviceName); + this._output.printLn(header); + this._footerText = '\n' + this._languageGenerator.generateFooter(saveStorage); + this._output.printLn(this._footerText); + } + + exit() { + this._output.flush(); + } + + addAction(action: ActionInContext) { + this.willPerformAction(action); + this.didPerformAction(action); + } + + willPerformAction(action: ActionInContext) { + this._currentAction = action; + } + + didPerformAction(actionInContext: ActionInContext) { + const { action, pageAlias } = actionInContext; + let eraseLastAction = false; + if (this._lastAction && this._lastAction.pageAlias === pageAlias) { + const { action: lastAction } = this._lastAction; + // We augment last action based on the type. + if (this._lastAction && action.name === 'fill' && lastAction.name === 'fill') { + if (action.selector === lastAction.selector) + eraseLastAction = true; + } + if (lastAction && action.name === 'click' && lastAction.name === 'click') { + if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount) + eraseLastAction = true; + } + if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') { + if (action.url === lastAction.url) + return; + } + for (const name of ['check', 'uncheck']) { + if (lastAction && action.name === name && lastAction.name === 'click') { + if ((action as any).selector === (lastAction as any).selector) + eraseLastAction = true; + } + } + } + this._printAction(actionInContext, eraseLastAction); + } + + commitLastAction() { + const action = this._lastAction; + if (action) + action.committed = true; + } + + _printAction(actionInContext: ActionInContext, eraseLastAction: boolean) { + this._output.popLn(this._footerText); + if (eraseLastAction && this._lastActionText) + this._output.popLn(this._lastActionText); + const performingAction = !!this._currentAction; + this._currentAction = undefined; + this._lastAction = actionInContext; + this._lastActionText = this._languageGenerator.generateAction(actionInContext, performingAction); + this._output.printLn(this._lastActionText); + this._output.printLn(this._footerText); + } + + signal(pageAlias: string, frame: Frame, signal: Signal) { + // Signal either arrives while action is being performed or shortly after. + if (this._currentAction) { + this._currentAction.action.signals.push(signal); + return; + } + if (this._lastAction && !this._lastAction.committed) { + this._lastAction.action.signals.push(signal); + this._printAction(this._lastAction, true); + return; + } + + if (signal.name === 'navigation') { + this.addAction({ + pageAlias, + frame, + committed: true, + action: { + name: 'navigate', + url: frame.url(), + signals: [], + } + }); + } + } +} diff --git a/src/cli/codegen/languages/csharp.ts b/src/cli/codegen/languages/csharp.ts new file mode 100644 index 0000000000..1c6c512030 --- /dev/null +++ b/src/cli/codegen/languages/csharp.ts @@ -0,0 +1,314 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContextOptions, LaunchOptions } from '../../../..'; +import * as playwright from '../../../..'; +import { HighlighterType, LanguageGenerator } from '.'; +import { ActionInContext } from '../codeGenerator'; +import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from '../recorderActions'; +import { MouseClickOptions, toModifiers } from '../utils'; + +export class CSharpLanguageGenerator implements LanguageGenerator { + + highligherType(): HighlighterType { + return 'csharp'; + } + + generateAction(actionInContext: ActionInContext, performingAction: boolean): string { + const { action, pageAlias, frame } = actionInContext; + const formatter = new CSharpFormatter(0); + formatter.newLine(); + formatter.add('// ' + actionTitle(action)); + + if (action.name === 'openPage') { + formatter.add(`var ${pageAlias} = await context.NewPageAsync();`); + if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') + formatter.add(`${pageAlias}.GoToAsync('${action.url}');`); + return formatter.format(); + } + + const subject = !frame.parentFrame() ? pageAlias : + `${pageAlias}.GetFrame(url: '${frame.url()}')`; + + let navigationSignal: NavigationSignal | undefined; + let popupSignal: PopupSignal | undefined; + let downloadSignal: DownloadSignal | undefined; + let dialogSignal: DialogSignal | undefined; + for (const signal of action.signals) { + if (signal.name === 'navigation') + navigationSignal = signal; + else if (signal.name === 'popup') + popupSignal = signal; + else if (signal.name === 'download') + downloadSignal = signal; + else if (signal.name === 'dialog') + dialogSignal = signal; + } + + if (dialogSignal) { + formatter.add(` void ${pageAlias}_Dialog${dialogSignal.dialogAlias}_EventHandler(object sender, DialogEventArgs e) + { + Console.WriteLine($"Dialog message: {e.Dialog.Message}"); + e.Dialog.DismissAsync(); + ${pageAlias}.Dialog -= ${pageAlias}_Dialog${dialogSignal.dialogAlias}_EventHandler; + } + ${pageAlias}.Dialog += ${pageAlias}_Dialog${dialogSignal.dialogAlias}_EventHandler;`); + } + + const waitForNavigation = navigationSignal && !performingAction; + const assertNavigation = navigationSignal && performingAction; + + const emitTaskWhenAll = waitForNavigation || popupSignal || downloadSignal; + if (emitTaskWhenAll) { + if (popupSignal) + formatter.add(`var ${popupSignal.popupAlias}Task = ${pageAlias}.WaitForEventAsync(PageEvent.Popup)`); + else if (downloadSignal) + formatter.add(`var downloadTask = ${pageAlias}.WaitForEventAsync(PageEvent.Download);`); + + formatter.add(`await Task.WhenAll(`); + } + + // Popup signals. + if (popupSignal) + formatter.add(`${popupSignal.popupAlias}Task,`); + + // Navigation signal. + if (waitForNavigation) + formatter.add(`${pageAlias}.WaitForNavigationAsync(/*${quote(navigationSignal!.url)}*/),`); + + // Download signals. + if (downloadSignal) + formatter.add(`downloadTask,`); + + const prefix = (popupSignal || waitForNavigation || downloadSignal) ? '' : 'await '; + const actionCall = this._generateActionCall(action); + const suffix = emitTaskWhenAll ? ');' : ';'; + formatter.add(`${prefix}${subject}.${actionCall}${suffix}`); + + if (assertNavigation) + formatter.add(` // Assert.Equal(${quote(navigationSignal!.url)}, ${pageAlias}.Url);`); + return formatter.format(); + } + + private _generateActionCall(action: Action): string { + switch (action.name) { + case 'openPage': + throw Error('Not reached'); + case 'closePage': + return 'CloseAsync()'; + case 'click': { + let method = 'ClickAsync'; + if (action.clickCount === 2) + method = 'DblClickAsync'; + const modifiers = toModifiers(action.modifiers); + const options: MouseClickOptions = {}; + if (action.button !== 'left') + options.button = action.button; + if (modifiers.length) + options.modifiers = modifiers; + if (action.clickCount > 2) + options.clickCount = action.clickCount; + const optionsString = formatOptions(options, true, false); + return `${method}(${quote(action.selector)}${optionsString})`; + } + case 'check': + return `CheckAsync(${quote(action.selector)})`; + case 'uncheck': + return `UncheckAsync(${quote(action.selector)})`; + case 'fill': + return `FillAsync(${quote(action.selector)}, ${quote(action.text)})`; + case 'setInputFiles': + return `SetInputFilesAsync(${quote(action.selector)}, ${formatObject(action.files.length === 1 ? action.files[0] : action.files)})`; + case 'press': { + const modifiers = toModifiers(action.modifiers); + const shortcut = [...modifiers, action.key].join('+'); + return `PressAsync(${quote(action.selector)}, ${quote(shortcut)})`; + } + case 'navigate': + return `GoToAsync(${quote(action.url)})`; + case 'select': + return `SelectOptionAsync(${quote(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})`; + } + } + + generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string { + const formatter = new CSharpFormatter(0); + formatter.add(` + await Playwright.InstallAsync(); + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.${toPascal(browserName)}.LaunchAsync(${formatArgs(launchOptions)}); + var context = await browser.NewContextAsync(${formatContextOptions(contextOptions, deviceName)});`); + return formatter.format(); + } + + generateFooter(saveStorage: string | undefined): string { + const storageStateLine = saveStorage ? `\nawait context.StorageStateAsync(path: "${saveStorage}")` : ''; + return `// ---------------------${storageStateLine}`; + } +} + +function formatValue(value: any): string { + if (value === false) + return 'false'; + if (value === true) + return 'true'; + if (value === undefined) + return 'null'; + if (Array.isArray(value)) + return `new [] {${value.map(formatValue).join(', ')}}`; + if (typeof value === 'string') + return quote(value); + return String(value); +} + +function formatOptions(value: any, hasArguments: boolean, isInitializing: boolean): string { + const keys = Object.keys(value); + if (!keys.length) + return ''; + return (hasArguments ? ', ' : '') + keys.map(key => `${key}${isInitializing ? ': ' : ' = '}${formatValue(value[key])}`).join(', '); +} + +function formatArgs(value: any, indent = ' '): string { + if (typeof value === 'string') + return quote(value); + if (Array.isArray(value)) + return `new [] {${value.map(o => formatObject(o)).join(', ')}}`; + if (typeof value === 'object') { + const keys = Object.keys(value); + if (!keys.length) + return ''; + const tokens: string[] = []; + for (const key of keys) + tokens.push(`${keys.length !== 1 ? indent : ''}${key}: ${formatObject(value[key], indent, key)}`); + if (keys.length === 1) + return `${tokens.join(`,\n${indent}`)}`; + else + return `\n${indent}${tokens.join(`,\n${indent}`)}`; + } + return String(value); +} + +function formatObject(value: any, indent = ' ', name = ''): string { + if (typeof value === 'string') { + if (name === 'permissions' || name === 'colorScheme') + return `${getClassName(name)}.${toPascal(value)}`; + return quote(value); + } + if (Array.isArray(value)) + return `new[] { ${value.map(o => formatObject(o, indent, name)).join(', ')} }`; + if (typeof value === 'object') { + const keys = Object.keys(value); + if (!keys.length) + return ''; + const tokens: string[] = []; + for (const key of keys) + tokens.push(`${toPascal(key)} = ${formatObject(value[key], indent, key)},`); + if (name) + return `new ${getClassName(name)}\n{\n${indent}${tokens.join(`\n${indent}`)}\n${indent}}`; + return `{\n${indent}${tokens.join(`\n${indent}`)}\n${indent}}`; + } + if (name === 'latitude' || name === 'longitude') + return String(value) + 'm'; + + return String(value); +} + +function getClassName(value: string): string { + switch (value) { + case 'viewport': return 'ViewportSize'; + case 'proxy': return 'ProxySettings'; + case 'permissions': return 'ContextPermission'; + default: return toPascal(value); + } +} + +function toPascal(value: string): string { + return value[0].toUpperCase() + value.slice(1); +} + +function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string { + const device = deviceName && playwright.devices[deviceName]; + if (!device) + return formatArgs(options); + // Filter out all the properties from the device descriptor. + const cleanedOptions: Record = {}; + for (const property in options) { + if ((device as any)[property] !== (options as any)[property]) + cleanedOptions[property] = (options as any)[property]; + } + const serializedObject = formatObject(cleanedOptions, ' '); + // When there are no additional context options, we still want to spread the device inside. + + if (!serializedObject) + return `playwright.Devices["${deviceName}"]`; + let result = `new BrowserContextOptions(playwright.Devices["${deviceName}"])`; + + if (serializedObject) { + const lines = serializedObject.split('\n'); + result = `${result} \n${lines.join('\n')}`; + } + + return result; +} + +class CSharpFormatter { + private _baseIndent: string; + private _baseOffset: string; + private _lines: string[] = []; + + constructor(offset = 0) { + this._baseIndent = ' '.repeat(4); + this._baseOffset = ' '.repeat(offset); + } + + prepend(text: string) { + this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines); + } + + add(text: string) { + this._lines.push(...text.trim().split('\n').map(line => line.trim())); + } + + newLine() { + this._lines.push(''); + } + + format(): string { + let spaces = ''; + let previousLine = ''; + return this._lines.map((line: string) => { + if (line === '') + return line; + if (line.startsWith('}') || line.startsWith(']') || line.includes('});')) + spaces = spaces.substring(this._baseIndent.length); + + const extraSpaces = /^(for|while|if).*\(.*\)$/.test(previousLine) ? this._baseIndent : ''; + previousLine = line; + + line = spaces + extraSpaces + line; + if (line.endsWith('{') || line.endsWith('[') || line.endsWith('(')) + spaces += this._baseIndent; + if (line.endsWith('});')) + spaces = spaces.substring(this._baseIndent.length); + + return this._baseOffset + line; + }).join('\n'); + } +} + +function quote(text: string) { + return `"${text.replace(/["]/g, '\\"')}"`; +} \ No newline at end of file diff --git a/src/cli/codegen/languages/index.ts b/src/cli/codegen/languages/index.ts new file mode 100644 index 0000000000..441b139ddb --- /dev/null +++ b/src/cli/codegen/languages/index.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContextOptions, LaunchOptions } from '../../../..'; +import { ActionInContext } from '../codeGenerator'; + +export type HighlighterType = 'javascript' | 'csharp' | 'python'; + +export interface LanguageGenerator { + generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string; + generateAction(actionInContext: ActionInContext, performingAction: boolean): string; + generateFooter(saveStorage: string | undefined): string; + highligherType(): HighlighterType; +} + +export { JavaScriptLanguageGenerator } from './javascript'; \ No newline at end of file diff --git a/src/cli/codegen/languages/javascript.ts b/src/cli/codegen/languages/javascript.ts new file mode 100644 index 0000000000..bf0abc7d48 --- /dev/null +++ b/src/cli/codegen/languages/javascript.ts @@ -0,0 +1,266 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContextOptions, LaunchOptions } from '../../../..'; +import * as playwright from '../../../..'; +import { HighlighterType, LanguageGenerator } from '.'; +import { ActionInContext } from '../codeGenerator'; +import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from '../recorderActions'; +import { MouseClickOptions, toModifiers } from '../utils'; + +export class JavaScriptLanguageGenerator implements LanguageGenerator { + + highligherType(): HighlighterType { + return 'javascript'; + } + + generateAction(actionInContext: ActionInContext, performingAction: boolean): string { + const { action, pageAlias, frame } = actionInContext; + const formatter = new JavaScriptFormatter(2); + formatter.newLine(); + formatter.add('// ' + actionTitle(action)); + + if (action.name === 'openPage') { + formatter.add(`const ${pageAlias} = await context.newPage();`); + if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') + formatter.add(`${pageAlias}.goto('${action.url}');`); + return formatter.format(); + } + + const subject = !frame.parentFrame() ? pageAlias : + `${pageAlias}.frame(${formatObject({ url: frame.url() })})`; + + let navigationSignal: NavigationSignal | undefined; + let popupSignal: PopupSignal | undefined; + let downloadSignal: DownloadSignal | undefined; + let dialogSignal: DialogSignal | undefined; + for (const signal of action.signals) { + if (signal.name === 'navigation') + navigationSignal = signal; + else if (signal.name === 'popup') + popupSignal = signal; + else if (signal.name === 'download') + downloadSignal = signal; + else if (signal.name === 'dialog') + dialogSignal = signal; + } + + if (dialogSignal) { + formatter.add(` ${pageAlias}.once('dialog', dialog => { + console.log(\`Dialog message: $\{dialog.message()}\`); + dialog.dismiss().catch(() => {}); + });`); + } + + const waitForNavigation = navigationSignal && !performingAction; + const assertNavigation = navigationSignal && performingAction; + + const emitPromiseAll = waitForNavigation || popupSignal || downloadSignal; + if (emitPromiseAll) { + // Generate either await Promise.all([]) or + // const [popup1] = await Promise.all([]). + let leftHandSide = ''; + if (popupSignal) + leftHandSide = `const [${popupSignal.popupAlias}] = `; + else if (downloadSignal) + leftHandSide = `const [download] = `; + formatter.add(`${leftHandSide}await Promise.all([`); + } + + // Popup signals. + if (popupSignal) + formatter.add(`${pageAlias}.waitForEvent('popup'),`); + + // Navigation signal. + if (waitForNavigation) + formatter.add(`${pageAlias}.waitForNavigation(/*{ url: ${quote(navigationSignal!.url)} }*/),`); + + // Download signals. + if (downloadSignal) + formatter.add(`${pageAlias}.waitForEvent('download'),`); + + const prefix = (popupSignal || waitForNavigation || downloadSignal) ? '' : 'await '; + const actionCall = this._generateActionCall(action); + const suffix = (waitForNavigation || emitPromiseAll) ? '' : ';'; + formatter.add(`${prefix}${subject}.${actionCall}${suffix}`); + + if (emitPromiseAll) + formatter.add(`]);`); + else if (assertNavigation) + formatter.add(` // assert.equal(${pageAlias}.url(), ${quote(navigationSignal!.url)});`); + return formatter.format(); + } + + private _generateActionCall(action: Action): string { + switch (action.name) { + case 'openPage': + throw Error('Not reached'); + case 'closePage': + return 'close()'; + case 'click': { + let method = 'click'; + if (action.clickCount === 2) + method = 'dblclick'; + const modifiers = toModifiers(action.modifiers); + const options: MouseClickOptions = {}; + if (action.button !== 'left') + options.button = action.button; + if (modifiers.length) + options.modifiers = modifiers; + if (action.clickCount > 2) + options.clickCount = action.clickCount; + const optionsString = formatOptions(options); + return `${method}(${quote(action.selector)}${optionsString})`; + } + case 'check': + return `check(${quote(action.selector)})`; + case 'uncheck': + return `uncheck(${quote(action.selector)})`; + case 'fill': + return `fill(${quote(action.selector)}, ${quote(action.text)})`; + case 'setInputFiles': + return `setInputFiles(${quote(action.selector)}, ${formatObject(action.files.length === 1 ? action.files[0] : action.files)})`; + case 'press': { + const modifiers = toModifiers(action.modifiers); + const shortcut = [...modifiers, action.key].join('+'); + return `press(${quote(action.selector)}, ${quote(shortcut)})`; + } + case 'navigate': + return `goto(${quote(action.url)})`; + case 'select': + return `selectOption(${quote(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})`; + } + } + + generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string { + const formatter = new JavaScriptFormatter(); + formatter.add(` + const { ${browserName}${deviceName ? ', devices' : ''} } = require('playwright'); + + (async () => { + const browser = await ${browserName}.launch(${formatObjectOrVoid(launchOptions)}); + const context = await browser.newContext(${formatContextOptions(contextOptions, deviceName)});`); + return formatter.format(); + } + + generateFooter(saveStorage: string | undefined): string { + const storageStateLine = saveStorage ? `\n await context.storageState({ path: '${saveStorage}' })` : ''; + return ` // ---------------------${storageStateLine} + await context.close(); + await browser.close(); +})();`; + } +} + +function formatOptions(value: any): string { + const keys = Object.keys(value); + if (!keys.length) + return ''; + return ', ' + formatObject(value); +} + +function formatObject(value: any, indent = ' '): string { + if (typeof value === 'string') + return quote(value); + if (Array.isArray(value)) + return `[${value.map(o => formatObject(o)).join(', ')}]`; + if (typeof value === 'object') { + const keys = Object.keys(value); + if (!keys.length) + return '{}'; + const tokens: string[] = []; + for (const key of keys) + tokens.push(`${key}: ${formatObject(value[key])}`); + return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`; + } + return String(value); +} + +function formatObjectOrVoid(value: any, indent = ' '): string { + const result = formatObject(value, indent); + return result === '{}' ? '' : result; +} + +function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string { + const device = deviceName && playwright.devices[deviceName]; + if (!device) + return formatObjectOrVoid(options); + // Filter out all the properties from the device descriptor. + const cleanedOptions: Record = {}; + for (const property in options) { + if ((device as any)[property] !== (options as any)[property]) + cleanedOptions[property] = (options as any)[property]; + } + let serializedObject = formatObjectOrVoid(cleanedOptions); + // When there are no additional context options, we still want to spread the device inside. + if (!serializedObject) + serializedObject = '{\n}'; + const lines = serializedObject.split('\n'); + lines.splice(1, 0, `...devices['${deviceName}'],`); + return lines.join('\n'); +} + +class JavaScriptFormatter { + private _baseIndent: string; + private _baseOffset: string; + private _lines: string[] = []; + + constructor(offset = 0) { + this._baseIndent = ' '.repeat(2); + this._baseOffset = ' '.repeat(offset); + } + + prepend(text: string) { + this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines); + } + + add(text: string) { + this._lines.push(...text.trim().split('\n').map(line => line.trim())); + } + + newLine() { + this._lines.push(''); + } + + format(): string { + let spaces = ''; + let previousLine = ''; + return this._lines.map((line: string) => { + if (line === '') + return line; + if (line.startsWith('}') || line.startsWith(']')) + spaces = spaces.substring(this._baseIndent.length); + + const extraSpaces = /^(for|while|if).*\(.*\)$/.test(previousLine) ? this._baseIndent : ''; + previousLine = line; + + line = spaces + extraSpaces + line; + if (line.endsWith('{') || line.endsWith('[')) + spaces += this._baseIndent; + return this._baseOffset + line; + }).join('\n'); + } +} + +function quote(text: string, char: string = '\'') { + if (char === '\'') + return char + text.replace(/[']/g, '\\\'') + char; + if (char === '"') + return char + text.replace(/["]/g, '\\"') + char; + if (char === '`') + return char + text.replace(/[`]/g, '\\`') + char; + throw new Error('Invalid escape char'); +} diff --git a/src/cli/codegen/languages/python.ts b/src/cli/codegen/languages/python.ts new file mode 100644 index 0000000000..835b1caedd --- /dev/null +++ b/src/cli/codegen/languages/python.ts @@ -0,0 +1,279 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { BrowserContextOptions, LaunchOptions } from '../../../..'; +import * as playwright from '../../../..'; +import { HighlighterType, LanguageGenerator } from '.'; +import { ActionInContext } from '../codeGenerator'; +import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from '../recorderActions'; +import { MouseClickOptions, toModifiers } from '../utils'; + +export class PythonLanguageGenerator implements LanguageGenerator { + private _awaitPrefix: '' | 'await '; + private _asyncPrefix: '' | 'async '; + private _isAsync: boolean; + + constructor(isAsync: boolean) { + this._isAsync = isAsync; + this._awaitPrefix = isAsync ? 'await ' : ''; + this._asyncPrefix = isAsync ? 'async ' : ''; + } + + highligherType(): HighlighterType { + return 'python'; + } + + generateAction(actionInContext: ActionInContext, performingAction: boolean): string { + const { action, pageAlias, frame } = actionInContext; + const formatter = new PythonFormatter(4); + formatter.newLine(); + formatter.add('# ' + actionTitle(action)); + + if (action.name === 'openPage') { + formatter.add(`${pageAlias} = ${this._awaitPrefix}context.newPage()`); + if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') + formatter.add(`${pageAlias}.goto('${action.url}')`); + return formatter.format(); + } + + const subject = !frame.parentFrame() ? pageAlias : + `${pageAlias}.frame(${formatOptions({ url: frame.url() }, false)})`; + + let navigationSignal: NavigationSignal | undefined; + let popupSignal: PopupSignal | undefined; + let downloadSignal: DownloadSignal | undefined; + let dialogSignal: DialogSignal | undefined; + for (const signal of action.signals) { + if (signal.name === 'navigation') + navigationSignal = signal; + else if (signal.name === 'popup') + popupSignal = signal; + else if (signal.name === 'download') + downloadSignal = signal; + else if (signal.name === 'dialog') + dialogSignal = signal; + } + + if (dialogSignal) + formatter.add(` ${pageAlias}.once("dialog", lambda dialog: asyncio.create_task(dialog.dismiss()))`); + + const waitForNavigation = navigationSignal && !performingAction; + const assertNavigation = navigationSignal && performingAction; + + const actionCall = this._generateActionCall(action); + let code = `${this._awaitPrefix}${subject}.${actionCall}`; + + if (popupSignal) { + code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as popup_info { + ${code} + } + ${popupSignal.popupAlias} = popup_info.value`; + } + + if (downloadSignal) { + code = `${this._asyncPrefix}with ${pageAlias}.expect_download() as download_info { + ${code} + } + download = download_info.value`; + } + + if (waitForNavigation) { + code = ` + # ${this._asyncPrefix}with ${pageAlias}.expect_navigation(url=${quote(navigationSignal!.url)}): + ${this._asyncPrefix}with ${pageAlias}.expect_navigation() { + ${code} + }`; + } + + formatter.add(code); + + if (assertNavigation) + formatter.add(` # assert ${pageAlias}.url == ${quote(navigationSignal!.url)}`); + return formatter.format(); + } + + private _generateActionCall(action: Action): string { + switch (action.name) { + case 'openPage': + throw Error('Not reached'); + case 'closePage': + return 'close()'; + case 'click': { + let method = 'click'; + if (action.clickCount === 2) + method = 'dblclick'; + const modifiers = toModifiers(action.modifiers); + const options: MouseClickOptions = {}; + if (action.button !== 'left') + options.button = action.button; + if (modifiers.length) + options.modifiers = modifiers; + if (action.clickCount > 2) + options.clickCount = action.clickCount; + const optionsString = formatOptions(options, true); + return `${method}(${quote(action.selector)}${optionsString})`; + } + case 'check': + return `check(${quote(action.selector)})`; + case 'uncheck': + return `uncheck(${quote(action.selector)})`; + case 'fill': + return `fill(${quote(action.selector)}, ${quote(action.text)})`; + case 'setInputFiles': + return `setInputFiles(${quote(action.selector)}, ${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; + case 'press': { + const modifiers = toModifiers(action.modifiers); + const shortcut = [...modifiers, action.key].join('+'); + return `press(${quote(action.selector)}, ${quote(shortcut)})`; + } + case 'navigate': + return `goto(${quote(action.url)})`; + case 'select': + return `selectOption(${quote(action.selector)}, ${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; + } + } + + generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string { + const formatter = new PythonFormatter(); + if (this._isAsync) { + formatter.add(` +import asyncio +from playwright import async_playwright + +async def run(playwright) { + browser = await playwright.${browserName}.launch(${formatOptions(launchOptions, false)}) + context = await browser.newContext(${formatContextOptions(contextOptions, deviceName)})`); + } else { + formatter.add(` +from playwright import sync_playwright + +def run(playwright) { + browser = playwright.${browserName}.launch(${formatOptions(launchOptions, false)}) + context = browser.newContext(${formatContextOptions(contextOptions, deviceName)})`); + } + return formatter.format(); + } + + generateFooter(saveStorage: string | undefined): string { + if (this._isAsync) { + const storageStateLine = saveStorage ? `\n await context.storageState(path="${saveStorage}")` : ''; + return ` # ---------------------${storageStateLine} + await context.close() + await browser.close() + +async def main(): + async with async_playwright() as playwright: + await run(playwright) +asyncio.run(main())`; + } else { + const storageStateLine = saveStorage ? `\n context.storageState(path="${saveStorage}")` : ''; + return ` # ---------------------${storageStateLine} + context.close() + browser.close() + +with sync_playwright() as playwright: + run(playwright)`; + } + } +} + +function formatValue(value: any): string { + if (value === false) + return 'False'; + if (value === true) + return 'True'; + if (value === undefined) + return 'None'; + if (Array.isArray(value)) + return `[${value.map(formatValue).join(', ')}]`; + if (typeof value === 'string') + return quote(value); + return String(value); +} + +function formatOptions(value: any, hasArguments: boolean): string { + const keys = Object.keys(value); + if (!keys.length) + return ''; + return (hasArguments ? ', ' : '') + keys.map(key => `${key}=${formatValue(value[key])}`).join(', '); +} + +function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string { + const device = deviceName && playwright.devices[deviceName]; + if (!device) + return formatOptions(options, false); + // Filter out all the properties from the device descriptor. + const cleanedOptions: Record = {}; + for (const property in options) { + if ((device as any)[property] !== (options as any)[property]) + cleanedOptions[property] = (options as any)[property]; + } + return `**playwright.devices["${deviceName}"]` + formatOptions(cleanedOptions, true); +} + +class PythonFormatter { + private _baseIndent: string; + private _baseOffset: string; + private _lines: string[] = []; + + constructor(offset = 0) { + this._baseIndent = ' '.repeat(4); + this._baseOffset = ' '.repeat(offset); + } + + prepend(text: string) { + this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines); + } + + add(text: string) { + this._lines.push(...text.trim().split('\n').map(line => line.trim())); + } + + newLine() { + this._lines.push(''); + } + + format(): string { + let spaces = ''; + const lines: string[] = []; + this._lines.forEach((line: string) => { + if (line === '') + return lines.push(line); + if (line === '}') { + spaces = spaces.substring(this._baseIndent.length); + return; + } + + line = spaces + line; + if (line.endsWith('{')) { + spaces += this._baseIndent; + line = line.substring(0, line.length - 1).trimEnd() + ':'; + } + return lines.push(this._baseOffset + line); + }); + return lines.join('\n'); + } +} + +function quote(text: string, char: string = '\"') { + if (char === '\'') + return char + text.replace(/[']/g, '\\\'') + char; + if (char === '"') + return char + text.replace(/["]/g, '\\"') + char; + if (char === '`') + return char + text.replace(/[`]/g, '\\`') + char; + throw new Error('Invalid escape char'); +} diff --git a/src/cli/codegen/outputs.ts b/src/cli/codegen/outputs.ts new file mode 100644 index 0000000000..4b2094ae03 --- /dev/null +++ b/src/cli/codegen/outputs.ts @@ -0,0 +1,114 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as querystring from 'querystring'; +import { Writable } from 'stream'; +import { highlight } from 'highlight.js'; +import { CodeGeneratorOutput } from './codeGenerator'; + +export class OutputMultiplexer implements CodeGeneratorOutput { + private _outputs: CodeGeneratorOutput[] + constructor(outputs: CodeGeneratorOutput[]) { + this._outputs = outputs; + } + + printLn(text: string) { + for (const output of this._outputs) + output.printLn(text); + } + + popLn(text: string) { + for (const output of this._outputs) + output.popLn(text); + } + + flush() { + for (const output of this._outputs) + output.flush(); + } +} + +export class FileOutput implements CodeGeneratorOutput { + private _fileName: string; + private _lines: string[]; + constructor(fileName: string) { + this._fileName = fileName; + this._lines = []; + } + + printLn(text: string) { + this._lines.push(...text.trimEnd().split('\n')); + } + + popLn(text: string) { + this._lines.length -= text.trimEnd().split('\n').length; + } + + flush() { + fs.writeFileSync(this._fileName, this._lines.join('\n')); + } +} + +export class TerminalOutput implements CodeGeneratorOutput { + private _output: Writable + private _language: string; + + constructor(output: Writable, language: string) { + this._output = output; + this._language = language; + } + + private _highlight(text: string) { + let highlightedCode = highlight(this._language, text).value; + highlightedCode = querystring.unescape(highlightedCode); + highlightedCode = highlightedCode.replace(//g, '\x1b[38;5;205m'); + highlightedCode = highlightedCode.replace(//g, '\x1b[38;5;220m'); + highlightedCode = highlightedCode.replace(//g, '\x1b[38;5;159m'); + highlightedCode = highlightedCode.replace(//g, ''); + highlightedCode = highlightedCode.replace(//g, '\x1b[38;5;78m'); + highlightedCode = highlightedCode.replace(//g, '\x1b[38;5;130m'); + highlightedCode = highlightedCode.replace(//g, '\x1b[38;5;23m'); + highlightedCode = highlightedCode.replace(//g, '\x1b[38;5;242m'); + highlightedCode = highlightedCode.replace(//g, ''); + highlightedCode = highlightedCode.replace(//g, ''); + highlightedCode = highlightedCode.replace(//g, ''); + highlightedCode = highlightedCode.replace(/<\/span>/g, '\x1b[0m'); + highlightedCode = highlightedCode.replace(/'/g, "'"); + highlightedCode = highlightedCode.replace(/"/g, '"'); + highlightedCode = highlightedCode.replace(/>/g, '>'); + highlightedCode = highlightedCode.replace(/</g, '<'); + highlightedCode = highlightedCode.replace(/&/g, '&'); + return highlightedCode; + } + + printLn(text: string) { + // Split into lines for highlighter to not fail. + for (const line of text.split('\n')) + this._output.write(this._highlight(line) + '\n'); + } + + popLn(text: string) { + const terminalWidth = process.stdout.columns || 80; + for (const line of text.split('\n')) { + const terminalLines = ((line.length - 1) / terminalWidth | 0) + 1; + for (let i = 0; i < terminalLines; ++i) + this._output.write('\u001B[1A\u001B[2K'); + } + } + + flush() {} +} diff --git a/src/cli/codegen/recorderActions.ts b/src/cli/codegen/recorderActions.ts new file mode 100644 index 0000000000..b4c1819d10 --- /dev/null +++ b/src/cli/codegen/recorderActions.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ActionName = + 'check' | + 'click' | + 'closePage' | + 'fill' | + 'navigate' | + 'openPage' | + 'press' | + 'select' | + 'uncheck' | + 'setInputFiles'; + +export type ActionBase = { + name: ActionName, + signals: Signal[], +} + +export type ClickAction = ActionBase & { + name: 'click', + selector: string, + button: 'left' | 'middle' | 'right', + modifiers: number, + clickCount: number, +}; + +export type CheckAction = ActionBase & { + name: 'check', + selector: string, +}; + +export type UncheckAction = ActionBase & { + name: 'uncheck', + selector: string, +}; + +export type FillAction = ActionBase & { + name: 'fill', + selector: string, + text: string, +}; + +export type NavigateAction = ActionBase & { + name: 'navigate', + url: string, +}; + +export type OpenPageAction = ActionBase & { + name: 'openPage', + url: string, +}; + +export type ClosesPageAction = ActionBase & { + name: 'closePage', +}; + +export type PressAction = ActionBase & { + name: 'press', + selector: string, + key: string, + modifiers: number, +}; + +export type SelectAction = ActionBase & { + name: 'select', + selector: string, + options: string[], +}; + +export type SetInputFilesAction = ActionBase & { + name: 'setInputFiles', + selector: string, + files: string[], +}; + +export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction; + +// Signals. + +export type NavigationSignal = { + name: 'navigation', + url: string, +}; + +export type PopupSignal = { + name: 'popup', + popupAlias: string, +}; + +export type DownloadSignal = { + name: 'download', +}; + +export type DialogSignal = { + name: 'dialog', + dialogAlias: string, +}; + +export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal; + +export function actionTitle(action: Action): string { + switch (action.name) { + case 'openPage': + return `Open new page`; + case 'closePage': + return `Close page`; + case 'check': + return `Check ${action.selector}`; + case 'uncheck': + return `Uncheck ${action.selector}`; + case 'click': { + if (action.clickCount === 1) + return `Click ${action.selector}`; + if (action.clickCount === 2) + return `Double click ${action.selector}`; + if (action.clickCount === 3) + return `Triple click ${action.selector}`; + return `${action.clickCount}× click`; + } + case 'fill': + return `Fill ${action.selector}`; + case 'setInputFiles': + if (action.files.length === 0) + return `Clear selected files`; + else + return `Upload ${action.files.join(', ')}`; + case 'navigate': + return `Go to ${action.url}`; + case 'press': + return `Press ${action.key}` + (action.modifiers ? ' with modifiers' : ''); + case 'select': + return `Select ${action.options.join(', ')}`; + } +} diff --git a/src/cli/codegen/recorderController.ts b/src/cli/codegen/recorderController.ts new file mode 100644 index 0000000000..c4a62d3a8b --- /dev/null +++ b/src/cli/codegen/recorderController.ts @@ -0,0 +1,164 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page, BrowserContext, Frame, Download, Dialog } from '../../..'; +import * as actions from './recorderActions'; +import { CodeGenerator, ActionInContext } from './codeGenerator'; +import { toClickOptions, toModifiers } from './utils'; +import * as recorderSource from '../../generated/recorderSource'; + +type BindingSource = { frame: Frame, page: Page }; + +export class RecorderController { + private _generator: CodeGenerator; + private _pageAliases = new Map(); + private _lastPopupOrdinal = 0; + private _lastDialogOrdinal = 0; + private _timers = new Set(); + + constructor(context: BrowserContext, generator: CodeGenerator) { + (context as any)._extendInjectedScript(recorderSource.source); + + this._generator = generator; + + // Input actions that potentially lead to navigation are intercepted on the page and are + // performed by the Playwright. + context.exposeBinding('performPlaywrightAction', + (source: BindingSource, action: actions.Action) => this._performAction(source.frame, action)).catch(e => {}); + + // Other non-essential actions are simply being recorded. + context.exposeBinding('recordPlaywrightAction', + (source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action)).catch(e => {}); + + // Commits last action so that no further signals are added to it. + context.exposeBinding('commitLastAction', + (source: BindingSource, action: actions.Action) => this._generator.commitLastAction()).catch(e => {}); + + context.on('page', page => this._onPage(page)); + for (const page of context.pages()) + this._onPage(page); + + context.once('close', () => { + for (const timer of this._timers) + clearTimeout(timer); + this._timers.clear(); + this._generator.exit(); + }); + } + + private async _onPage(page: Page) { + // First page is called page, others are called popup1, popup2, etc. + page.on('close', () => { + this._pageAliases.delete(page); + this._generator.addAction({ + pageAlias, + frame: page.mainFrame(), + committed: true, + action: { + name: 'closePage', + signals: [], + } + }); + }); + page.on('framenavigated', frame => this._onFrameNavigated(frame, page)); + page.on('download', download => this._onDownload(page, download)); + page.on('popup', popup => this._onPopup(page, popup)); + page.on('dialog', dialog => this._onDialog(page, dialog)); + const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : ''; + const pageAlias = 'page' + suffix; + this._pageAliases.set(page, pageAlias); + + const isPopup = !!await page.opener(); + // Could happen due to the await above. + if (page.isClosed()) + return; + if (!isPopup) { + this._generator.addAction({ + pageAlias, + frame: page.mainFrame(), + committed: true, + action: { + name: 'openPage', + url: page.url(), + signals: [], + } + }); + } + } + + private async _performAction(frame: Frame, action: actions.Action) { + const page = frame.page(); + const actionInContext: ActionInContext = { + pageAlias: this._pageAliases.get(page)!, + frame, + action + }; + this._generator.willPerformAction(actionInContext); + if (action.name === 'click') { + const { options } = toClickOptions(action); + await frame.click(action.selector, options); + } + if (action.name === 'press') { + const modifiers = toModifiers(action.modifiers); + const shortcut = [...modifiers, action.key].join('+'); + await frame.press(action.selector, shortcut); + } + if (action.name === 'check') + await frame.check(action.selector); + if (action.name === 'uncheck') + await frame.uncheck(action.selector); + if (action.name === 'select') + await frame.selectOption(action.selector, action.options); + const timer = setTimeout(() => { + actionInContext.committed = true; + this._timers.delete(timer); + }, 5000); + this._generator.didPerformAction(actionInContext); + this._timers.add(timer); + } + + private async _recordAction(frame: Frame, action: actions.Action) { + // We are lacking frame.page() in + this._generator.addAction({ + pageAlias: this._pageAliases.get(frame.page())!, + frame, + action + }); + } + + private _onFrameNavigated(frame: Frame, page: Page) { + if (frame.parentFrame()) + return; + const pageAlias = this._pageAliases.get(page); + this._generator.signal(pageAlias!, frame, { name: 'navigation', url: frame.url() }); + } + + private _onPopup(page: Page, popup: Page) { + const pageAlias = this._pageAliases.get(page)!; + const popupAlias = this._pageAliases.get(popup)!; + this._generator.signal(pageAlias, page.mainFrame(), { name: 'popup', popupAlias }); + } + private _onDownload(page: Page, download: Download) { + const pageAlias = this._pageAliases.get(page)!; + this._generator.signal(pageAlias, page.mainFrame(), { name: 'download' }); + } + + private _onDialog(page: Page, dialog: Dialog) { + const pageAlias = this._pageAliases.get(page)!; + this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) }); + } +} + diff --git a/src/cli/codegen/utils.ts b/src/cli/codegen/utils.ts new file mode 100644 index 0000000000..fb80c094a0 --- /dev/null +++ b/src/cli/codegen/utils.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from '../../..'; +import * as actions from './recorderActions'; + +export type MouseClickOptions = Parameters[1]; + +export function toClickOptions(action: actions.ClickAction): { method: 'click' | 'dblclick', options: MouseClickOptions } { + let method: 'click' | 'dblclick' = 'click'; + if (action.clickCount === 2) + method = 'dblclick'; + const modifiers = toModifiers(action.modifiers); + const options: MouseClickOptions = {}; + if (action.button !== 'left') + options.button = action.button; + if (modifiers.length) + options.modifiers = modifiers; + if (action.clickCount > 2) + options.clickCount = action.clickCount; + return { method, options }; +} + +export function toModifiers(modifiers: number): ('Alt' | 'Control' | 'Meta' | 'Shift')[] { + const result: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = []; + if (modifiers & 1) + result.push('Alt'); + if (modifiers & 2) + result.push('Control'); + if (modifiers & 4) + result.push('Meta'); + if (modifiers & 8) + result.push('Shift'); + return result; +} diff --git a/src/debug/injected/html.ts b/src/cli/injected/html.ts similarity index 100% rename from src/debug/injected/html.ts rename to src/cli/injected/html.ts diff --git a/src/cli/injected/recorder.ts b/src/cli/injected/recorder.ts new file mode 100644 index 0000000000..32ca7e698a --- /dev/null +++ b/src/cli/injected/recorder.ts @@ -0,0 +1,514 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type * as actions from '../codegen/recorderActions'; +import type InjectedScript from '../../server/injected/injectedScript'; +import { generateSelector } from '../../debug/injected/selectorGenerator'; +import { html } from './html'; + +declare global { + interface Window { + performPlaywrightAction: (action: actions.Action) => Promise; + recordPlaywrightAction: (action: actions.Action) => Promise; + commitLastAction: () => Promise; + } +} + +const scriptSymbol = Symbol('scriptSymbol'); + +export class Recorder { + private _injectedScript: InjectedScript; + private _performingAction = false; + private _outerGlassPaneElement: HTMLElement; + private _glassPaneShadow: ShadowRoot; + private _innerGlassPaneElement: HTMLElement; + private _highlightElements: HTMLElement[] = []; + private _tooltipElement: HTMLElement; + private _listeners: RegisteredListener[] = []; + private _hoveredModel: HighlightModel | null = null; + private _hoveredElement: HTMLElement | null = null; + private _activeModel: HighlightModel | null = null; + private _expectProgrammaticKeyUp = false; + + constructor(injectedScript: InjectedScript) { + this._injectedScript = injectedScript; + + this._outerGlassPaneElement = html` + + `; + + this._tooltipElement = html``; + + this._innerGlassPaneElement = html` + + ${this._tooltipElement} + `; + + // Use a closed shadow root to prevent selectors matching our internal previews. + this._glassPaneShadow = this._outerGlassPaneElement.attachShadow({ mode: 'closed' }); + this._glassPaneShadow.appendChild(this._innerGlassPaneElement); + this._glassPaneShadow.appendChild(html` + + `); + setInterval(() => { + this._refreshListenersIfNeeded(); + }, 100); + } + + private _refreshListenersIfNeeded() { + if ((document.documentElement as any)[scriptSymbol]) + return; + (document.documentElement as any)[scriptSymbol] = true; + removeEventListeners(this._listeners); + this._listeners = [ + addEventListener(document, 'click', event => this._onClick(event as MouseEvent), true), + addEventListener(document, 'input', event => this._onInput(event), true), + addEventListener(document, 'keydown', event => this._onKeyDown(event as KeyboardEvent), true), + addEventListener(document, 'keyup', event => this._onKeyUp(event as KeyboardEvent), true), + addEventListener(document, 'mousedown', event => this._onMouseDown(event as MouseEvent), true), + addEventListener(document, 'mouseup', event => this._onMouseUp(event as MouseEvent), true), + addEventListener(document, 'mousemove', event => this._onMouseMove(event as MouseEvent), true), + addEventListener(document, 'mouseleave', event => this._onMouseLeave(event as MouseEvent), true), + addEventListener(document, 'focus', () => this._onFocus(), true), + addEventListener(document, 'scroll', () => { + this._hoveredModel = null; + this._updateHighlight(); + }, true), + ]; + document.documentElement.appendChild(this._outerGlassPaneElement); + if ((window as any)._recorderScriptReadyForTest) + (window as any)._recorderScriptReadyForTest(); + } + + private _actionInProgress(event: Event): boolean { + // If Playwright is performing action for us, bail. + if (this._performingAction) + return true; + // Consume as the first thing. + consumeEvent(event); + return false; + } + + private _consumedDueToNoModel(event: Event, model: HighlightModel | null): boolean { + if (model) + return false; + consumeEvent(event); + return true; + } + + private _consumedDueWrongTarget(event: Event): boolean { + if (this._activeModel && this._activeModel.elements[0] === deepEventTarget(event)) + return false; + consumeEvent(event); + return true; + } + + private _onClick(event: MouseEvent) { + if (this._shouldIgnoreMouseEvent(event)) + return; + if (this._actionInProgress(event)) + return; + if (this._consumedDueToNoModel(event, this._hoveredModel)) + return; + + const checkbox = asCheckbox(deepEventTarget(event)); + if (checkbox) { + // Interestingly, inputElement.checked is reversed inside this event handler. + this._performAction({ + name: checkbox.checked ? 'check' : 'uncheck', + selector: this._hoveredModel!.selector, + signals: [], + }); + return; + } + + this._performAction({ + name: 'click', + selector: this._hoveredModel!.selector, + signals: [], + button: buttonForEvent(event), + modifiers: modifiersForEvent(event), + clickCount: event.detail + }); + } + + private _shouldIgnoreMouseEvent(event: MouseEvent): boolean { + const target = deepEventTarget(event); + const nodeName = target.nodeName; + if (nodeName === 'SELECT') + return true; + if (nodeName === 'INPUT' && ['date'].includes((target as HTMLInputElement).type)) + return true; + return false; + } + + private _onMouseDown(event: MouseEvent) { + if (this._shouldIgnoreMouseEvent(event)) + return; + if (!this._performingAction) + consumeEvent(event); + this._activeModel = this._hoveredModel; + } + + private _onMouseUp(event: MouseEvent) { + if (this._shouldIgnoreMouseEvent(event)) + return; + if (!this._performingAction) + consumeEvent(event); + } + + private _onMouseMove(event: MouseEvent) { + const target = deepEventTarget(event); + if (this._hoveredElement === target) + return; + this._hoveredElement = target; + // Mouse moved -> mark last action as committed via committing a commit action. + this._commitActionAndUpdateModelForHoveredElement(); + } + + private _onMouseLeave(event: MouseEvent) { + // Leaving iframe. + if (deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) { + this._hoveredElement = null; + this._commitActionAndUpdateModelForHoveredElement(); + } + } + + private _onFocus() { + const activeElement = deepActiveElement(document); + const result = activeElement ? generateSelector(this._injectedScript, activeElement) : null; + this._activeModel = result && result.selector ? result : null; + if ((window as any)._highlightUpdatedForTest) + (window as any)._highlightUpdatedForTest(result ? result.selector : null); + } + + private _commitActionAndUpdateModelForHoveredElement() { + if (!this._hoveredElement) { + this._hoveredModel = null; + this._updateHighlight(); + return; + } + const hoveredElement = this._hoveredElement; + const { selector, elements } = generateSelector(this._injectedScript, hoveredElement); + if ((this._hoveredModel && this._hoveredModel.selector === selector) || this._hoveredElement !== hoveredElement) + return; + window.commitLastAction(); + this._hoveredModel = selector ? { selector, elements } : null; + this._updateHighlight(); + if ((window as any)._highlightUpdatedForTest) + (window as any)._highlightUpdatedForTest(selector); + } + + private _updateHighlight() { + const elements = this._hoveredModel ? this._hoveredModel.elements : []; + + // Code below should trigger one layout and leave with the + // destroyed layout. + + // Destroy the layout + this._tooltipElement.textContent = this._hoveredModel ? this._hoveredModel.selector : ''; + this._tooltipElement.style.top = '0'; + this._tooltipElement.style.left = '0'; + this._tooltipElement.style.display = 'flex'; + + // Trigger layout. + const boxes = elements.map(e => e.getBoundingClientRect()); + const tooltipWidth = this._tooltipElement.offsetWidth; + const tooltipHeight = this._tooltipElement.offsetHeight; + const totalWidth = this._innerGlassPaneElement.offsetWidth; + const totalHeight = this._innerGlassPaneElement.offsetHeight; + + // Destroy the layout again. + if (boxes.length) { + const primaryBox = boxes[0]; + let anchorLeft = primaryBox.left; + if (anchorLeft + tooltipWidth > totalWidth - 5) + anchorLeft = totalWidth - tooltipWidth - 5; + let anchorTop = primaryBox.bottom + 5; + if (anchorTop + tooltipHeight > totalHeight - 5) { + // If can't fit below, either position above... + if (primaryBox.top > tooltipHeight + 5) { + anchorTop = primaryBox.top - tooltipHeight - 5; + } else { + // Or on top in case of large element + anchorTop = totalHeight - 5 - tooltipHeight; + } + } + this._tooltipElement.style.top = anchorTop + 'px'; + this._tooltipElement.style.left = anchorLeft + 'px'; + } else { + this._tooltipElement.style.display = 'none'; + } + + const pool = this._highlightElements; + this._highlightElements = []; + for (const box of boxes) { + const highlightElement = pool.length ? pool.shift()! : this._createHighlightElement(); + highlightElement.style.borderColor = this._highlightElements.length ? 'hotpink' : '#8929ff'; + highlightElement.style.left = box.x + 'px'; + highlightElement.style.top = box.y + 'px'; + highlightElement.style.width = box.width + 'px'; + highlightElement.style.height = box.height + 'px'; + highlightElement.style.display = 'block'; + this._highlightElements.push(highlightElement); + } + + for (const highlightElement of pool) { + highlightElement.style.display = 'none'; + this._highlightElements.push(highlightElement); + } + } + + private _createHighlightElement(): HTMLElement { + const highlightElement = html` + + `; + this._glassPaneShadow.appendChild(highlightElement); + return highlightElement; + } + + private _onInput(event: Event) { + const target = deepEventTarget(event); + if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) { + const inputElement = target as HTMLInputElement; + const elementType = (inputElement.type || '').toLowerCase(); + if (elementType === 'checkbox') { + // Checkbox is handled in click, we can't let input trigger on checkbox - that would mean we dispatched click events while recording. + return; + } + + if (elementType === 'file') { + window.recordPlaywrightAction({ + name: 'setInputFiles', + selector: this._activeModel!.selector, + signals: [], + files: [...(inputElement.files || [])].map(file => file.name), + }); + return; + } + + // Non-navigating actions are simply recorded by Playwright. + if (this._consumedDueWrongTarget(event)) + return; + window.recordPlaywrightAction({ + name: 'fill', + selector: this._activeModel!.selector, + signals: [], + text: inputElement.value, + }); + } + + if (target.nodeName === 'SELECT') { + const selectElement = target as HTMLSelectElement; + if (this._actionInProgress(event)) + return; + this._performAction({ + name: 'select', + selector: this._hoveredModel!.selector, + options: [...selectElement.selectedOptions].map(option => option.value), + signals: [] + }); + } + } + + private _shouldGenerateKeyPressFor(event: KeyboardEvent): boolean { + // Backspace, Delete are changing input, will handle it there. + if (['Backspace', 'Delete'].includes(event.key)) + return false; + // Ignore the QWERTZ shortcut for creating a at sign on MacOS + if (event.key === '@' && event.code === 'KeyL') + return false; + // Allow and ignore common used shortcut for pasting. + if (process.platform === 'darwin') { + if (event.key === 'v' && event.metaKey) + return false; + } else { + if (event.key === 'v' && event.ctrlKey) + return false; + if (event.key === 'Insert' && event.shiftKey) + return false; + } + if (['Shift', 'Control', 'Meta', 'Alt'].includes(event.key)) + return false; + const hasModifier = event.ctrlKey || event.altKey || event.metaKey; + if (event.key.length === 1 && !hasModifier) + return !!asCheckbox(deepEventTarget(event)); + return true; + } + + private _onKeyDown(event: KeyboardEvent) { + if (!this._shouldGenerateKeyPressFor(event)) + return; + if (this._actionInProgress(event)) { + this._expectProgrammaticKeyUp = true; + return; + } + if (this._consumedDueWrongTarget(event)) + return; + // Similarly to click, trigger checkbox on key event, not input. + if (event.key === ' ') { + const checkbox = asCheckbox(deepEventTarget(event)); + if (checkbox) { + this._performAction({ + name: checkbox.checked ? 'uncheck' : 'check', + selector: this._activeModel!.selector, + signals: [], + }); + return; + } + } + + this._performAction({ + name: 'press', + selector: this._activeModel!.selector, + signals: [], + key: event.key, + modifiers: modifiersForEvent(event), + }); + } + + private _onKeyUp(event: KeyboardEvent) { + if (!this._shouldGenerateKeyPressFor(event)) + return; + + // Only allow programmatic keyups, ignore user input. + if (!this._expectProgrammaticKeyUp) { + consumeEvent(event); + return; + } + this._expectProgrammaticKeyUp = false; + } + + private async _performAction(action: actions.Action) { + this._performingAction = true; + await window.performPlaywrightAction(action); + this._performingAction = false; + + // Action could have changed DOM, update hovered model selectors. + this._commitActionAndUpdateModelForHoveredElement(); + // If that was a keyboard action, it similarly requires new selectors for active model. + this._onFocus(); + + if ((window as any)._actionPerformedForTest) { + (window as any)._actionPerformedForTest({ + hovered: this._hoveredModel ? this._hoveredModel.selector : null, + active: this._activeModel ? this._activeModel.selector : null, + }); + } + } +} + +function deepEventTarget(event: Event): HTMLElement { + return event.composedPath()[0] as HTMLElement; +} + +function deepActiveElement(document: Document): Element | null { + let activeElement = document.activeElement; + while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) + activeElement = activeElement.shadowRoot.activeElement; + return activeElement; +} + +function modifiersForEvent(event: MouseEvent | KeyboardEvent): number { + return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0); +} + +function buttonForEvent(event: MouseEvent): 'left' | 'middle' | 'right' { + switch (event.which) { + case 1: return 'left'; + case 2: return 'middle'; + case 3: return 'right'; + } + return 'left'; +} + +function consumeEvent(e: Event) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); +} + +type HighlightModel = { + selector: string; + elements: Element[]; +}; + +function asCheckbox(node: Node | null): HTMLInputElement | null { + if (!node || node.nodeName !== 'INPUT') + return null; + const inputElement = node as HTMLInputElement; + return inputElement.type === 'checkbox' ? inputElement : null; +} + +type RegisteredListener = { + target: EventTarget; + eventName: string; + listener: EventListener; + useCapture?: boolean; +}; + +function addEventListener(target: EventTarget, eventName: string, listener: EventListener, useCapture?: boolean): RegisteredListener { + target.addEventListener(eventName, listener, useCapture); + return { target, eventName, listener, useCapture }; +} + +function removeEventListeners(listeners: RegisteredListener[]) { + for (const listener of listeners) + listener.target.removeEventListener(listener.eventName, listener.listener, listener.useCapture); + listeners.splice(0, listeners.length); +} + +export default Recorder; diff --git a/src/cli/injected/recorder.webpack.config.js b/src/cli/injected/recorder.webpack.config.js new file mode 100644 index 0000000000..ba86b05300 --- /dev/null +++ b/src/cli/injected/recorder.webpack.config.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); +const InlineSource = require('../../server/injected/webpack-inline-source-plugin'); + +module.exports = { + entry: path.join(__dirname, 'recorder.ts'), + devtool: 'source-map', + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + loader: 'ts-loader', + options: { + transpileOnly: true + }, + exclude: /node_modules/ + } + ] + }, + resolve: { + extensions: [ '.tsx', '.ts', '.js' ] + }, + output: { + libraryTarget: 'var', + filename: 'recorderSource.js', + path: path.resolve(__dirname, '../../../lib/server/injected/packed') + }, + plugins: [ + new InlineSource(path.join(__dirname, '..', '..', 'generated', 'recorderSource.ts')), + ] +}; diff --git a/test/assets/file-to-upload-2.txt b/test/assets/file-to-upload-2.txt new file mode 100644 index 0000000000..b4ad118489 --- /dev/null +++ b/test/assets/file-to-upload-2.txt @@ -0,0 +1 @@ +contents of the file \ No newline at end of file diff --git a/test/cli/cli-codegen-csharp.spec.ts b/test/cli/cli-codegen-csharp.spec.ts new file mode 100644 index 0000000000..d9a5523aa6 --- /dev/null +++ b/test/cli/cli-codegen-csharp.spec.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import { folio } from './cli.fixtures'; + +const { it, expect } = folio; + +const emptyHTML = new URL('file://' + path.join(__dirname, '..', 'assets', 'empty.html')).toString(); + +it('should print the correct imports and context options', async ({ runCLI }) => { + const cli = runCLI(['codegen', '--target=csharp', emptyHTML]); + const expectedResult = `await Playwright.InstallAsync(); +using var playwright = await Playwright.CreateAsync(); +await using var browser = await playwright.Chromium.LaunchAsync(headless: false); +var context = await browser.NewContextAsync();`; + await cli.waitFor(expectedResult).catch(e => e); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options for custom settings', async ({ runCLI }) => { + const cli = runCLI([ + '--color-scheme=dark', + '--geolocation=37.819722,-122.478611', + '--lang=es', + '--proxy-server=http://myproxy:3128', + '--timezone=Europe/Rome', + '--timeout=1000', + '--user-agent=hardkodemium', + '--viewport-size=1280,720', + 'codegen', + '--target=csharp', + emptyHTML]); + const expectedResult = `await Playwright.InstallAsync(); +using var playwright = await Playwright.CreateAsync(); +await using var browser = await playwright.Chromium.LaunchAsync( + headless: false, + proxy: new ProxySettings + { + Server = "http://myproxy:3128", + }); +var context = await browser.NewContextAsync( + viewport: new ViewportSize + { + Width = 1280, + Height = 720, + }, + geolocation: new Geolocation + { + Latitude = 37.819722m, + Longitude = -122.478611m, + }, + permissions: new[] { ContextPermission.Geolocation }, + userAgent: "hardkodemium", + locale: "es", + colorScheme: ColorScheme.Dark, + timezoneId: "Europe/Rome");`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options when using a device', async ({ runCLI }) => { + const cli = runCLI(['--device=Pixel 2', 'codegen', '--target=csharp', emptyHTML]); + const expectedResult = `await Playwright.InstallAsync(); +using var playwright = await Playwright.CreateAsync(); +await using var browser = await playwright.Chromium.LaunchAsync(headless: false); +var context = await browser.NewContextAsync(playwright.Devices["Pixel 2"]);`; + + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options when using a device and additional options', async ({ runCLI }) => { + const cli = runCLI([ + '--device=Pixel 2', + '--color-scheme=dark', + '--geolocation=37.819722,-122.478611', + '--lang=es', + '--proxy-server=http://myproxy:3128', + '--timezone=Europe/Rome', + '--timeout=1000', + '--user-agent=hardkodemium', + '--viewport-size=1280,720', + 'codegen', + '--target=csharp', + emptyHTML]); + const expectedResult = `await Playwright.InstallAsync(); +using var playwright = await Playwright.CreateAsync(); +await using var browser = await playwright.Chromium.LaunchAsync( + headless: false, + proxy: new ProxySettings + { + Server = "http://myproxy:3128", + }); +var context = await browser.NewContextAsync(new BrowserContextOptions(playwright.Devices["Pixel 2"]) +{ + UserAgent = "hardkodemium", + Viewport = new ViewportSize + { + Width = 1280, + Height = 720, + }, + Geolocation = new Geolocation + { + Latitude = 37.819722m, + Longitude = -122.478611m, + }, + Permissions = new[] { ContextPermission.Geolocation }, + Locale = "es", + ColorScheme = ColorScheme.Dark, + TimezoneId = "Europe/Rome", +});`; + + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print load/save storageState', async ({ runCLI, testInfo }) => { + const loadFileName = testInfo.outputPath('load.json'); + const saveFileName = testInfo.outputPath('save.json'); + await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8'); + const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, 'codegen', '--target=csharp', emptyHTML]); + const expectedResult = `await Playwright.InstallAsync(); +using var playwright = await Playwright.CreateAsync(); +await using var browser = await playwright.Chromium.LaunchAsync(); +var context = await browser.NewContextAsync(storageState: "${loadFileName}"); + +// Open new page +var page = await context.NewPageAsync(); + +// --------------------- +await context.StorageStateAsync(path: "${saveFileName}"); +`; + await cli.waitFor(expectedResult); +}); diff --git a/test/cli/cli-codegen-javascript.spec.ts b/test/cli/cli-codegen-javascript.spec.ts new file mode 100644 index 0000000000..d77adcf371 --- /dev/null +++ b/test/cli/cli-codegen-javascript.spec.ts @@ -0,0 +1,134 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { folio } from './cli.fixtures'; + +const { it, expect } = folio; + +const emptyHTML = new URL('file://' + path.join(__dirname, '..', 'assets', 'empty.html')).toString(); + +it('should print the correct imports and context options', async ({ runCLI }) => { + const cli = runCLI(['codegen', emptyHTML]); + const expectedResult = `const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ + headless: false + }); + const context = await browser.newContext();`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options for custom settings', async ({ runCLI }) => { + const cli = runCLI(['--color-scheme=light', 'codegen', emptyHTML]); + const expectedResult = `const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ + headless: false + }); + const context = await browser.newContext({ + colorScheme: 'light' + });`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + + +it('should print the correct context options when using a device', async ({ runCLI }) => { + const cli = runCLI(['--device=Pixel 2', 'codegen', emptyHTML]); + const expectedResult = `const { chromium, devices } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ + headless: false + }); + const context = await browser.newContext({ + ...devices['Pixel 2'], + });`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options when using a device and additional options', async ({ runCLI }) => { + const cli = runCLI(['--color-scheme=light', '--device=Pixel 2', 'codegen', emptyHTML]); + const expectedResult = `const { chromium, devices } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ + headless: false + }); + const context = await browser.newContext({ + ...devices['Pixel 2'], + colorScheme: 'light' + });`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should save the codegen output to a file if specified', async ({ runCLI, testInfo }) => { + const tmpFile = testInfo.outputPath('script.js'); + const cli = runCLI(['codegen', '--output', tmpFile, emptyHTML]); + await cli.exited; + const content = await fs.readFileSync(tmpFile); + expect(content.toString()).toBe(`const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ + headless: false + }); + const context = await browser.newContext(); + + // Open new page + const page = await context.newPage(); + + // Go to ${emptyHTML} + await page.goto('${emptyHTML}'); + + // Close page + await page.close(); + + // --------------------- + await context.close(); + await browser.close(); +})();`); +}); + +it('should print load/save storageState', async ({ runCLI, testInfo }) => { + const loadFileName = testInfo.outputPath('load.json'); + const saveFileName = testInfo.outputPath('save.json'); + await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8'); + const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, 'codegen', emptyHTML]); + const expectedResult = `const { chromium, devices } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ + headless: false + }); + const context = await browser.newContext({ + storageState: '${loadFileName}' + }); + + // --------------------- + await context.storageState({ path: '${saveFileName}' }); + await context.close(); + await browser.close(); +})();`; + await cli.waitFor(expectedResult); +}); diff --git a/test/cli/cli-codegen-python-async.spec.ts b/test/cli/cli-codegen-python-async.spec.ts new file mode 100644 index 0000000000..32194abe0c --- /dev/null +++ b/test/cli/cli-codegen-python-async.spec.ts @@ -0,0 +1,129 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { folio } from './cli.fixtures'; + +const { it, expect } = folio; + +const emptyHTML = new URL('file://' + path.join(__dirname, '..', 'assets', 'empty.html')).toString(); + +it('should print the correct imports and context options', async ({ runCLI }) => { + const cli = runCLI(['codegen', '--target=python-async', emptyHTML]); + const expectedResult = `import asyncio +from playwright import async_playwright + +async def run(playwright): + browser = await playwright.chromium.launch(headless=False) + context = await browser.newContext()`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options for custom settings', async ({ runCLI }) => { + const cli = runCLI(['--color-scheme=light', 'codegen', '--target=python-async', emptyHTML]); + const expectedResult = `import asyncio +from playwright import async_playwright + +async def run(playwright): + browser = await playwright.chromium.launch(headless=False) + context = await browser.newContext(colorScheme="light")`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options when using a device', async ({ runCLI }) => { + const cli = runCLI(['--device=Pixel 2', 'codegen', '--target=python-async', emptyHTML]); + const expectedResult = `import asyncio +from playwright import async_playwright + +async def run(playwright): + browser = await playwright.chromium.launch(headless=False) + context = await browser.newContext(**playwright.devices["Pixel 2"])`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options when using a device and additional options', async ({ runCLI }) => { + const cli = runCLI(['--color-scheme=light', '--device=Pixel 2', 'codegen', '--target=python-async', emptyHTML]); + const expectedResult = `import asyncio +from playwright import async_playwright + +async def run(playwright): + browser = await playwright.chromium.launch(headless=False) + context = await browser.newContext(**playwright.devices["Pixel 2"], colorScheme="light")`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should save the codegen output to a file if specified', async ({ runCLI, testInfo }) => { + const tmpFile = testInfo.outputPath('script.js'); + const cli = runCLI(['codegen', '--target=python-async', '--output', tmpFile, emptyHTML]); + await cli.exited; + const content = await fs.readFileSync(tmpFile); + expect(content.toString()).toBe(`import asyncio +from playwright import async_playwright + +async def run(playwright): + browser = await playwright.chromium.launch(headless=False) + context = await browser.newContext() + + # Open new page + page = await context.newPage() + + # Go to ${emptyHTML} + await page.goto("${emptyHTML}") + + # Close page + await page.close() + + # --------------------- + await context.close() + await browser.close() + +async def main(): + async with async_playwright() as playwright: + await run(playwright) +asyncio.run(main())`); +}); + +it('should print load/save storageState', async ({ runCLI, testInfo }) => { + const loadFileName = testInfo.outputPath('load.json'); + const saveFileName = testInfo.outputPath('save.json'); + await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8'); + const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, 'codegen', '--target=python-async', emptyHTML]); + const expectedResult = `import asyncio + from playwright import async_playwright + + async def run(playwright): + browser = await playwright.chromium.launch(headless=False) + context = await browser.newContext(storageState="${loadFileName}") + + # Open new page + page = await context.newPage() + + # --------------------- + await context.storageState(path="${saveFileName}") + await context.close() + await browser.close() + + async def main(): + async with async_playwright() as playwright: + await run(playwright) + asyncio.run(main())`; + await cli.waitFor(expectedResult); +}); diff --git a/test/cli/cli-codegen-python.spec.ts b/test/cli/cli-codegen-python.spec.ts new file mode 100644 index 0000000000..f7c1426f0a --- /dev/null +++ b/test/cli/cli-codegen-python.spec.ts @@ -0,0 +1,119 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { folio } from './cli.fixtures'; + +const { it, expect } = folio; + +const emptyHTML = new URL('file://' + path.join(__dirname, '..', 'assets', 'empty.html')).toString(); + +it('should print the correct imports and context options', async ({ runCLI }) => { + const cli = runCLI(['codegen', '--target=python', emptyHTML]); + const expectedResult = `from playwright import sync_playwright + +def run(playwright): + browser = playwright.chromium.launch(headless=False) + context = browser.newContext()`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options for custom settings', async ({ runCLI }) => { + const cli = runCLI(['--color-scheme=light', 'codegen', '--target=python', emptyHTML]); + const expectedResult = `from playwright import sync_playwright + +def run(playwright): + browser = playwright.chromium.launch(headless=False) + context = browser.newContext(colorScheme="light")`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options when using a device', async ({ runCLI }) => { + const cli = runCLI(['--device=Pixel 2', 'codegen', '--target=python', emptyHTML]); + const expectedResult = `from playwright import sync_playwright + +def run(playwright): + browser = playwright.chromium.launch(headless=False) + context = browser.newContext(**playwright.devices["Pixel 2"])`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should print the correct context options when using a device and additional options', async ({ runCLI }) => { + const cli = runCLI(['--color-scheme=light', '--device=Pixel 2', 'codegen', '--target=python', emptyHTML]); + const expectedResult = `from playwright import sync_playwright + +def run(playwright): + browser = playwright.chromium.launch(headless=False) + context = browser.newContext(**playwright.devices["Pixel 2"], colorScheme="light")`; + await cli.waitFor(expectedResult); + expect(cli.text()).toContain(expectedResult); +}); + +it('should save the codegen output to a file if specified', async ({ runCLI, testInfo }) => { + const tmpFile = testInfo.outputPath('script.js'); + const cli = runCLI(['codegen', '--target=python', '--output', tmpFile, emptyHTML]); + await cli.exited; + const content = fs.readFileSync(tmpFile); + expect(content.toString()).toBe(`from playwright import sync_playwright + +def run(playwright): + browser = playwright.chromium.launch(headless=False) + context = browser.newContext() + + # Open new page + page = context.newPage() + + # Go to ${emptyHTML} + page.goto("${emptyHTML}") + + # Close page + page.close() + + # --------------------- + context.close() + browser.close() + +with sync_playwright() as playwright: + run(playwright)`); +}); + +it('should print load/save storageState', async ({ runCLI, testInfo }) => { + const loadFileName = testInfo.outputPath('load.json'); + const saveFileName = testInfo.outputPath('save.json'); + await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8'); + const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, 'codegen', '--target=python', emptyHTML]); + const expectedResult = `from playwright import sync_playwright + + def run(playwright): + browser = playwright.chromium.launch(headless=False) + context = browser.newContext(storageState="${loadFileName}") + + # Open new page + page = context.newPage() + + # --------------------- + context.storageState(path="${saveFileName}") + context.close() + browser.close() + + with sync_playwright() as playwright: + run(playwright)`; + await cli.waitFor(expectedResult); +}); diff --git a/test/cli/cli-codegen.spec.ts b/test/cli/cli-codegen.spec.ts new file mode 100644 index 0000000000..c3245370f7 --- /dev/null +++ b/test/cli/cli-codegen.spec.ts @@ -0,0 +1,581 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { folio } from './cli.fixtures'; +import * as http from 'http'; +import * as url from 'url'; + +const { it, expect } = folio; + +it('should click', async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + + const selector = await recorder.hoverOverElement('button'); + expect(selector).toBe('text="Submit"'); + + const [message] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('click'), + page.dispatchEvent('button', 'click', { detail: 1 }) + ]); + expect(recorder.output()).toContain(` + // Click text="Submit" + await page.click('text="Submit"');`); + expect(message.text()).toBe('click'); +}); + +it('should not target selector preview by text regexp', async ({ page, recorder }) => { + await recorder.setContentAndWait(`dummy`); + + // Force highlight. + await recorder.hoverOverElement('span'); + + // Append text after highlight. + await page.evaluate(() => { + const div = document.createElement('div'); + div.setAttribute('onclick', "console.log('click')"); + div.textContent = ' Some long text here '; + document.documentElement.appendChild(div); + }); + + const selector = await recorder.hoverOverElement('div'); + expect(selector).toBe('text=/.*Some long text here.*/'); + + // Sanity check that selector does not match our highlight. + const divContents = await page.$eval(selector, div => div.outerHTML); + expect(divContents).toBe(`
Some long text here
`); + + const [message] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('click'), + page.dispatchEvent('div', 'click', { detail: 1 }) + ]); + expect(recorder.output()).toContain(` + // Click text=/.*Some long text here.*/ + await page.click('text=/.*Some long text here.*/');`); + expect(message.text()).toBe('click'); +}); + +it('should fill', async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + const selector = await recorder.focusElement('input'); + expect(selector).toBe('input[name="name"]'); + + const [message] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('fill'), + page.fill('input', 'John') + ]); + expect(recorder.output()).toContain(` + // Fill input[name="name"] + await page.fill('input[name="name"]', 'John');`); + expect(message.text()).toBe('John'); +}); + +it('should fill textarea', async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + const selector = await recorder.focusElement('textarea'); + expect(selector).toBe('textarea[name="name"]'); + + const [message] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('fill'), + page.fill('textarea', 'John') + ]); + expect(recorder.output()).toContain(` + // Fill textarea[name="name"] + await page.fill('textarea[name="name"]', 'John');`); + expect(message.text()).toBe('John'); +}); + +it('should press', async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + + const selector = await recorder.focusElement('input'); + expect(selector).toBe('input[name="name"]'); + + const messages: any[] = []; + page.on('console', message => messages.push(message)), + await Promise.all([ + recorder.waitForActionPerformed(), + recorder.waitForOutput('press'), + page.press('input', 'Shift+Enter') + ]); + expect(recorder.output()).toContain(` + // Press Enter with modifiers + await page.press('input[name="name"]', 'Shift+Enter');`); + expect(messages[0].text()).toBe('press'); +}); + +it('should update selected element after pressing Tab', async ({ page, recorder }) => { + await recorder.setContentAndWait(` + + + `); + + await page.click('input[name="one"]'); + await recorder.waitForOutput('click'); + await page.keyboard.type('foobar123'); + await recorder.waitForOutput('foobar123'); + + await page.keyboard.press('Tab'); + await recorder.waitForOutput('Tab'); + await page.keyboard.type('barfoo321'); + await recorder.waitForOutput('barfoo321'); + + expect(recorder.output()).toContain(` + // Fill input[name="one"] + await page.fill('input[name="one"]', 'foobar123');`); + + expect(recorder.output()).toContain(` + // Press Tab + await page.press('input[name="one"]', 'Tab');`); + + expect(recorder.output()).toContain(` + // Fill input[name="two"] + await page.fill('input[name="two"]', 'barfoo321');`); +}); + +it('should record ArrowDown', async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + + const selector = await recorder.focusElement('input'); + expect(selector).toBe('input[name="name"]'); + + const messages: any[] = []; + page.on('console', message => { + messages.push(message); + }), + await Promise.all([ + recorder.waitForActionPerformed(), + recorder.waitForOutput('press'), + page.press('input', 'ArrowDown') + ]); + expect(recorder.output()).toContain(` + // Press ArrowDown + await page.press('input[name="name"]', 'ArrowDown');`); + expect(messages[0].text()).toBe('press:ArrowDown'); +}); + +it('should emit single keyup on ArrowDown', async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + + const selector = await recorder.focusElement('input'); + expect(selector).toBe('input[name="name"]'); + + const messages: any[] = []; + page.on('console', message => { + messages.push(message); + }), + await Promise.all([ + recorder.waitForActionPerformed(), + recorder.waitForOutput('press'), + page.press('input', 'ArrowDown') + ]); + expect(recorder.output()).toContain(` + // Press ArrowDown + await page.press('input[name="name"]', 'ArrowDown');`); + expect(messages.length).toBe(2); + expect(messages[0].text()).toBe('down:ArrowDown'); + expect(messages[1].text()).toBe('up:ArrowDown'); +}); + +it('should check', (test, { browserName, headful }) => { + test.fixme(browserName === 'firefox' && headful, 'Focus is off'); +}, async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + + const selector = await recorder.focusElement('input'); + expect(selector).toBe('input[name="accept"]'); + + const [message] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('check'), + page.click('input') + ]); + await recorder.waitForOutput('check'); + expect(recorder.output()).toContain(` + // Check input[name="accept"] + await page.check('input[name="accept"]');`); + expect(message.text()).toBe('true'); +}); + +it('should check with keyboard', (test, { browserName, headful }) => { + test.fixme(browserName === 'firefox' && headful, 'Focus is off'); +}, async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + + const selector = await recorder.focusElement('input'); + expect(selector).toBe('input[name="accept"]'); + + const [message] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('check'), + page.keyboard.press('Space') + ]); + await recorder.waitForOutput('check'); + expect(recorder.output()).toContain(` + // Check input[name="accept"] + await page.check('input[name="accept"]');`); + expect(message.text()).toBe('true'); +}); + +it('should uncheck', (test, { browserName, headful }) => { + test.fixme(browserName === 'firefox' && headful, 'Focus is off'); +}, async ({ page, recorder }) => { + await recorder.setContentAndWait(``); + + const selector = await recorder.focusElement('input'); + expect(selector).toBe('input[name="accept"]'); + + const [message] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('uncheck'), + page.click('input') + ]); + expect(recorder.output()).toContain(` + // Uncheck input[name="accept"] + await page.uncheck('input[name="accept"]');`); + expect(message.text()).toBe('false'); +}); + +it('should select', async ({ page, recorder }) => { + await recorder.setContentAndWait(''); + + const selector = await recorder.hoverOverElement('select'); + expect(selector).toBe('select[id="age"]'); + + const [message] = await Promise.all([ + page.waitForEvent('console'), + recorder.waitForOutput('select'), + page.selectOption('select', '2') + ]); + expect(recorder.output()).toContain(` + // Select 2 + await page.selectOption('select[id="age"]', '2');`); + expect(message.text()).toBe('2'); +}); + +it('should await popup', (test, { browserName, headful }) => { + test.fixme(browserName === 'webkit' && headful, 'Middle click does not open a popup in our webkit embedder'); +}, async ({ page, recorder }) => { + await recorder.setContentAndWait('link'); + + const selector = await recorder.hoverOverElement('a'); + expect(selector).toBe('text="link"'); + + const [popup] = await Promise.all([ + page.context().waitForEvent('page'), + recorder.waitForOutput('waitForEvent'), + page.dispatchEvent('a', 'click', { detail: 1 }) + ]); + expect(recorder.output()).toContain(` + // Click text="link" + const [page1] = await Promise.all([ + page.waitForEvent('popup'), + page.click('text="link"') + ]);`); + expect(popup.url()).toBe('about:blank'); +}); + +it('should assert navigation', async ({ page, recorder }) => { + await recorder.setContentAndWait(`link`); + + const selector = await recorder.hoverOverElement('a'); + expect(selector).toBe('text="link"'); + + await Promise.all([ + page.waitForNavigation(), + recorder.waitForOutput('assert'), + page.dispatchEvent('a', 'click', { detail: 1 }) + ]); + expect(recorder.output()).toContain(` + // Click text="link" + await page.click('text="link"'); + // assert.equal(page.url(), 'about:blank#foo');`); + expect(page.url()).toContain('about:blank#foo'); +}); + + +it('should await navigation', async ({ page, recorder }) => { + await recorder.setContentAndWait(`link`); + + const selector = await recorder.hoverOverElement('a'); + expect(selector).toBe('text="link"'); + + await Promise.all([ + page.waitForNavigation(), + recorder.waitForOutput('waitForNavigation'), + page.dispatchEvent('a', 'click', { detail: 1 }) + ]); + expect(recorder.output()).toContain(` + // Click text="link" + await Promise.all([ + page.waitForNavigation(/*{ url: 'about:blank#foo' }*/), + page.click('text="link"') + ]);`); + expect(page.url()).toContain('about:blank#foo'); +}); + +it('should contain open page', async ({ recorder }) => { + await recorder.setContentAndWait(``); + expect(recorder.output()).toContain(`const page = await context.newPage();`); +}); + +it('should contain second page', async ({ contextWrapper, recorder }) => { + await recorder.setContentAndWait(``); + await contextWrapper.context.newPage(); + await recorder.waitForOutput('page1'); + expect(recorder.output()).toContain('const page1 = await context.newPage();'); +}); + +it('should contain close page', async ({ contextWrapper, recorder }) => { + await recorder.setContentAndWait(``); + await contextWrapper.context.newPage(); + await recorder.page.close(); + await recorder.waitForOutput('page.close();'); +}); + +it('should not lead to an error if /html gets clicked', async ({ contextWrapper, recorder }) => { + await recorder.setContentAndWait(''); + await contextWrapper.context.newPage(); + const errors: any[] = []; + recorder.page.on('pageerror', e => errors.push(e)); + await recorder.page.evaluate(() => document.querySelector('body').remove()); + const selector = await recorder.hoverOverElement('html'); + expect(selector).toBe('/html'); + await recorder.page.close(); + await recorder.waitForOutput('page.close();'); + expect(errors.length).toBe(0); +}); + +it('should upload a single file', async ({ page, recorder }) => { + await recorder.setContentAndWait(` +
+ +
+`); + + await page.focus('input[type=file]'); + await page.setInputFiles('input[type=file]', 'test/assets/file-to-upload.txt'); + await page.click('input[type=file]'); + + await recorder.waitForOutput('setInputFiles'); + expect(recorder.output()).toContain(` + // Upload file-to-upload.txt + await page.setInputFiles('input[type="file"]', 'file-to-upload.txt');`); +}); + +it('should upload multiple files', async ({ page, recorder }) => { + await recorder.setContentAndWait(` +
+ +
+`); + + await page.focus('input[type=file]'); + await page.setInputFiles('input[type=file]', ['test/assets/file-to-upload.txt', 'test/assets/file-to-upload-2.txt']); + await page.click('input[type=file]'); + + await recorder.waitForOutput('setInputFiles'); + expect(recorder.output()).toContain(` + // Upload file-to-upload.txt, file-to-upload-2.txt + await page.setInputFiles('input[type="file"]', ['file-to-upload.txt', 'file-to-upload-2.txt']);`); +}); + +it('should clear files', async ({ page, recorder }) => { + await recorder.setContentAndWait(` +
+ +
+`); + await page.focus('input[type=file]'); + await page.setInputFiles('input[type=file]', 'test/assets/file-to-upload.txt'); + await page.setInputFiles('input[type=file]', []); + await page.click('input[type=file]'); + + await recorder.waitForOutput('setInputFiles'); + expect(recorder.output()).toContain(` + // Clear selected files + await page.setInputFiles('input[type="file"]', []);`); +}); + +it('should download files', async ({ page, recorder, httpServer }) => { + httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => { + const pathName = url.parse(req.url!).path; + if (pathName === '/download') { + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', 'attachment; filename=file.txt'); + res.end(`Hello world`); + } else { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(''); + } + }); + await recorder.setContentAndWait(` + Download + `, httpServer.PREFIX); + await recorder.hoverOverElement('text=Download'); + await Promise.all([ + page.waitForEvent('download'), + page.click('text=Download') + ]); + await recorder.waitForOutput('page.click'); + expect(recorder.output()).toContain(` + // Click text="Download" + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('text="Download"') + ]);`); +}); + +it('should handle dialogs', async ({ page, recorder }) => { + await recorder.setContentAndWait(` + + `); + await recorder.hoverOverElement('button'); + page.once('dialog', async dialog => { + await dialog.dismiss(); + }); + await page.click('text="click me"'); + await recorder.waitForOutput('page.once'); + expect(recorder.output()).toContain(` + // Click text="click me" + page.once('dialog', dialog => { + console.log(\`Dialog message: $\{dialog.message()}\`); + dialog.dismiss().catch(() => {}); + }); + await page.click('text="click me"')`); +}); + +it('should handle history.postData', async ({ page, recorder, httpServer }) => { + httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end('Hello world'); + }); + await recorder.setContentAndWait(` + `, httpServer.PREFIX); + for (let i = 1; i < 3; ++i) { + await page.evaluate('pushState()'); + await recorder.waitForOutput(`seqNum=${i}`); + expect(recorder.output()).toContain(`await page.goto('${httpServer.PREFIX}/#seqNum=${i}');`); + } +}); + +it('should record open in a new tab with url', (test, { browserName }) => { + test.fixme(browserName === 'webkit', 'Ctrl+click does not open in new tab on WebKit'); +}, async ({ page, recorder, browserName, platform }) => { + await recorder.setContentAndWait(`link`); + + const selector = await recorder.hoverOverElement('a'); + expect(selector).toBe('text="link"'); + + await page.click('a', { modifiers: [ platform === 'darwin' ? 'Meta' : 'Control'] }); + await recorder.waitForOutput('page1'); + if (browserName === 'chromium') { + expect(recorder.output()).toContain(` + // Open new page + const page1 = await context.newPage(); + page1.goto('about:blank?foo');`); + } else if (browserName === 'firefox') { + expect(recorder.output()).toContain(` + // Click text="link" + const [page1] = await Promise.all([ + page.waitForEvent('popup'), + page.click('text="link"', { + modifiers: ['${platform === 'darwin' ? 'Meta' : 'Control'}'] + }) + ]);`); + } +}); + +it('should not clash pages', (test, { browserName }) => { + test.fixme(browserName === 'firefox', 'Times out on Firefox, maybe the focus issue'); +}, async ({ page, recorder }) => { + const [popup1] = await Promise.all([ + page.context().waitForEvent('page'), + page.evaluate(`window.open('about:blank')`) + ]); + await recorder.setPageContentAndWait(popup1, ''); + + const [popup2] = await Promise.all([ + page.context().waitForEvent('page'), + page.evaluate(`window.open('about:blank')`) + ]); + await recorder.setPageContentAndWait(popup2, ''); + + await popup1.type('input', 'TextA'); + await recorder.waitForOutput('TextA'); + + await popup2.type('input', 'TextB'); + await recorder.waitForOutput('TextB'); + + expect(recorder.output()).toContain(`await page1.fill('input[id="name"]', 'TextA');`); + expect(recorder.output()).toContain(`await page2.fill('input[id="name"]', 'TextB');`); +}); + +it('click should emit events in order', async ({ page, recorder }) => { + await recorder.setContentAndWait(` +