feat: refactoring to provide cross-lang support (#64)

This commit is contained in:
Max Schmitt
2020-09-13 04:03:26 +02:00
committed by GitHub
parent bc0dffeb58
commit f7963b6e38
15 changed files with 260 additions and 210 deletions
+6 -3
View File
@@ -21,8 +21,9 @@ import * as os from 'os';
import * as playwright from 'playwright';
import { Browser, BrowserContext, Page } from 'playwright';
import { ScriptController } from './scriptController';
import { OutputMultiplexer, TerminalOutput, FileOutput } from './outputs'
import { CodeGeneratorOutput } from './codeGenerator';
import { OutputMultiplexer, TerminalOutput, FileOutput } from './codegen/outputs'
import { CodeGeneratorOutput } from './codegen/codeGenerator';
import { JavaScriptLanguageGenerator } from './codegen/languages';
program
.version('Version ' + require('../package.json').version)
@@ -252,7 +253,9 @@ async function open(options: Options, url: string | undefined, enableRecorder: b
const outputs: CodeGeneratorOutput[] = [new TerminalOutput(process.stdout)];
if (outputFile)
outputs.push(new FileOutput(outputFile));
new ScriptController(browserName, launchOptions, contextOptions, context, new OutputMultiplexer(outputs), enableRecorder, options.device);
const output = new OutputMultiplexer(outputs)
const languageGenerator = new JavaScriptLanguageGenerator(output)
new ScriptController(browserName, launchOptions, contextOptions, context, output, languageGenerator, enableRecorder, options.device);
await openPage(context, url);
if (process.env.PWCLI_EXIT_FOR_TEST)
await Promise.all(context.pages().map(p => p.close()))
+132
View File
@@ -0,0 +1,132 @@
/**
* 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 playwright from 'playwright';
import { Frame } from 'playwright';
import { LanguageGenerator } from './languages'
import { Action, Signal } from './recorderActions';
export type ActionInContext = {
pageAlias: string;
frame: Frame;
action: Action;
committed?: boolean;
}
export interface CodeGeneratorOutput {
write(text: string): void
popLine(): void
flush(): void
}
export class CodeGenerator {
private _currentAction: ActionInContext | undefined;
private _lastAction: ActionInContext | undefined;
private _lastActionText: string | undefined;
private _languageGenerator: LanguageGenerator;
private _output: CodeGeneratorOutput;
constructor(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, deviceName: string | undefined) {
this._output = output
this._languageGenerator = languageGenerator
launchOptions = { headless: false, ...launchOptions };
this._languageGenerator.writeHeader(browserName, launchOptions, contextOptions, deviceName)
}
exit() {
this._languageGenerator.writeFooter()
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._languageGenerator.preWriteAction(eraseLastAction, this._lastActionText)
const performingAction = !!this._currentAction;
this._currentAction = undefined;
this._lastAction = actionInContext;
this._lastActionText = this._languageGenerator.generateAction(actionInContext, performingAction)
this._languageGenerator.postWriteAction(this._lastActionText)
}
signal(pageAlias: string, frame: playwright.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: [],
}
});
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import * as playwright from 'playwright';
import { ActionInContext } from '../codeGenerator';
export interface LanguageGenerator {
writeHeader(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, deviceName?: string): void
generateAction(actionInContext: ActionInContext, performingAction: boolean): string
preWriteAction(eraseLastAction: boolean, lastActionText?: string): void
postWriteAction(lastActionText: string): void
writeFooter(): void
}
export { JavaScriptLanguageGenerator } from './javascript'
@@ -15,139 +15,34 @@
*/
import * as playwright from 'playwright';
import { Frame } from 'playwright';
import { quote, Formatter } from './formatter';
import { Action, actionTitle, NavigationSignal, PopupSignal, Signal, DownloadSignal, DialogSignal } from './recorderActions';
import { MouseClickOptions, toModifiers } from './utils';
import { LanguageGenerator } from '.';
import { ActionInContext, CodeGeneratorOutput } from '../codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from '../recorderActions'
import { MouseClickOptions, toModifiers } from '../../utils';
export type ActionInContext = {
pageAlias: string;
frame: Frame;
action: Action;
committed?: boolean;
}
export interface CodeGeneratorOutput {
write(text: string): void
popLine(): void
flush(): void
}
export class CodeGenerator {
private _currentAction: ActionInContext | undefined;
private _lastAction: ActionInContext | undefined;
private _lastActionText: string | undefined;
private _output: CodeGeneratorOutput;
constructor(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, output: CodeGeneratorOutput, deviceName: string | undefined) {
this._output = output
const formatter = new Formatter();
launchOptions = { headless: false, ...launchOptions };
formatter.add(`
const { ${browserName}${deviceName ? ', devices' : ''} } = require('playwright');
(async () => {
const browser = await ${browserName}.launch(${formatObjectOrVoid(launchOptions)});
const context = await browser.newContext(${formatContextOptions(contextOptions, deviceName)});
})();`);
this._output.write(formatter.format() + '\n');
export class JavaScriptLanguageGenerator implements LanguageGenerator {
private _output: CodeGeneratorOutput
constructor(output: CodeGeneratorOutput) {
this._output = output;
}
exit() {
this._output.popLine();
this._output.write(' // Close browser\n');
this._output.write(' await browser.close();\n})();\n');
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) {
preWriteAction(eraseLastAction: boolean, lastActionText?: string): void {
// We erase terminating `})();` at all times.
let eraseLines = 1;
if (eraseLastAction && this._lastActionText)
eraseLines += this._lastActionText.split('\n').length;
if (eraseLastAction && lastActionText)
eraseLines += lastActionText.split('\n').length;
// And we erase the last action too if augmenting.
for (let i = 0; i < eraseLines; ++i)
this._output.popLine()
const performingAction = !!this._currentAction;
this._currentAction = undefined;
this._lastAction = actionInContext;
this._lastActionText = this._generateAction(actionInContext, performingAction);
this._output.write(this._lastActionText + '\n})();\n');
}
signal(pageAlias: string, frame: playwright.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: [],
}
});
}
postWriteAction(lastActionText: string): void {
this._output.write(lastActionText + '\n})();\n');
}
private _generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
const { action, pageAlias, frame } = actionInContext;
const formatter = new Formatter(2);
const formatter = new JavaScriptFormatter(2);
formatter.newLine();
formatter.add('// ' + actionTitle(action));
@@ -223,7 +118,7 @@ export class CodeGenerator {
}
private _generateActionCall(action: Action): string {
switch (action.name) {
switch (action.name) {
case 'openPage':
throw Error('Not reached');
case 'closePage':
@@ -262,6 +157,24 @@ export class CodeGenerator {
return `selectOption(${quote(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})`;
}
}
writeHeader(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, deviceName?: string): void {
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)});
})();`);
this._output.write(formatter.format() + '\n');
}
writeFooter(): void {
this._output.popLine();
this._output.write(' // Close browser\n');
this._output.write(' await browser.close();\n})();\n');
}
}
function formatOptions(value: any): string {
@@ -299,7 +212,7 @@ function formatContextOptions(options: playwright.BrowserContextOptions, deviceN
return formatObjectOrVoid(options);
// Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {}
for(const property in options)
for (const property in options)
if ((device as any)[property] !== (options as any)[property])
cleanedOptions[property] = (options as any)[property]
let serializedObject = formatObjectOrVoid(cleanedOptions);
@@ -309,4 +222,56 @@ function formatContextOptions(options: playwright.BrowserContextOptions, deviceN
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');
}
@@ -17,7 +17,8 @@
import * as playwright from 'playwright';
import * as actions from './recorderActions';
import { CodeGenerator, ActionInContext, CodeGeneratorOutput } from './codeGenerator';
import { BindingSource, toClickOptions, toModifiers } from './utils';
import { BindingSource, toClickOptions, toModifiers } from '../utils';
import { LanguageGenerator } from './languages';
export class RecorderController {
private _generator: CodeGenerator;
@@ -25,8 +26,8 @@ export class RecorderController {
private _lastPopupOrdinal = 0;
private _timers = new Set<NodeJS.Timeout>();
constructor(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, context: playwright.BrowserContext, output: CodeGeneratorOutput, deviceName: string | undefined) {
this._generator = new CodeGenerator(browserName, launchOptions, contextOptions, output, deviceName);
constructor(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, context: playwright.BrowserContext, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, deviceName: string | undefined) {
this._generator = new CodeGenerator(browserName, launchOptions, contextOptions, output, languageGenerator, deviceName);
// Input actions that potentially lead to navigation are intercepted on the page and are
// performed by the Playwright.
-67
View File
@@ -1,67 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export class Formatter {
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');
}
}
export 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');
}
+1 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type * as actions from '../recorderActions';
import type * as actions from '../codegen/recorderActions';
import { ConsoleAPI, InjectedScript } from './consoleApi';
import { html } from './html';
import { addEventListener, RegisteredListener, removeEventListeners } from './util';
+1 -2
View File
@@ -19,13 +19,12 @@ import { Recorder } from './recorder';
export default class Script {
private _consoleAPI: ConsoleAPI | undefined;
private _recorder: Recorder | undefined;
constructor(injectedScript: InjectedScript, options: { enableRecorder: boolean }) {
if ((window as any).playwright)
return;
this._consoleAPI = new ConsoleAPI(injectedScript);
if (options.enableRecorder)
this._recorder = new Recorder(injectedScript, this._consoleAPI);
new Recorder(injectedScript, this._consoleAPI);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { ConsoleAPI, InjectedScript } from './consoleApi';
import { InjectedScript } from './consoleApi';
/**
* Copyright (c) Microsoft Corporation.
*
+5 -4
View File
@@ -16,15 +16,16 @@
import * as playwright from 'playwright';
import * as injectedScriptSource from './generated/scriptSource';
import { RecorderController } from './recorderController';
import { CodeGeneratorOutput } from './codeGenerator';
import { RecorderController } from './codegen/recorderController';
import { CodeGeneratorOutput } from './codegen/codeGenerator';
import { LanguageGenerator } from './codegen/languages';
export class ScriptController {
private _recorder: RecorderController | undefined;
constructor(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, context: playwright.BrowserContext, output: CodeGeneratorOutput, enableRecorder: boolean, deviceName?: string) {
constructor(browserName: string, launchOptions: playwright.LaunchOptions, contextOptions: playwright.BrowserContextOptions, context: playwright.BrowserContext, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, enableRecorder: boolean, deviceName?: string) {
if (enableRecorder)
this._recorder = new RecorderController(browserName, launchOptions, contextOptions, context, output, deviceName);
this._recorder = new RecorderController(browserName, launchOptions, contextOptions, context, output, languageGenerator, deviceName);
context.on('page', page => this._onPage(page));
for (const page of context.pages())
this._onPage(page);
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import * as playwright from 'playwright';
import * as actions from './recorderActions';
import * as actions from './codegen/recorderActions';
let __dummy: { page: playwright.Page };
export type MouseClickOptions = Parameters<typeof __dummy.page.click>[1];
+7 -4
View File
@@ -23,7 +23,8 @@ import * as playwright from 'playwright';
import { fixtures as baseFixtures } from '@playwright/test-runner';
import { ScriptController } from '../src/scriptController';
import { Page } from 'playwright';
import { TerminalOutput } from '../src/outputs';
import { TerminalOutput } from '../src/codegen/outputs';
import { JavaScriptLanguageGenerator } from '../src/codegen/languages';
type Parameters = {
browserName: string;
@@ -102,9 +103,11 @@ fixtures.defineWorkerFixture('httpServer', async ({parallelIndex}, runTest) => {
fixtures.defineTestFixture('contextWrapper', async ({ browser }, runTest, info) => {
const context = await browser.newContext();
const output = new WritableBuffer();
new ScriptController('chromium', {}, {}, context, new TerminalOutput(output as any as Writable), true);
await runTest({ context, output });
const outputBuffer = new WritableBuffer();
const output = new TerminalOutput(outputBuffer as any as Writable)
const languageGenerator = new JavaScriptLanguageGenerator(output)
new ScriptController('chromium', {}, {}, context, output, languageGenerator, true);
await runTest({ context, output: outputBuffer });
await context.close();
});
+2 -1
View File
@@ -7,7 +7,8 @@
"rootDir": "./src",
"outDir": "./lib",
"strict": true,
"declaration": false
"declaration": false,
"noUnusedLocals": true
},
"compileOnSave": true,
"include": ["src/**/*.ts"],