chore: make recorder a supplement (#5131)

This commit is contained in:
Pavel Feldman 2021-01-24 19:21:19 -08:00 committed by GitHub
parent be9bef513e
commit 464fdc1800
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 341 additions and 226 deletions

View File

@ -22,16 +22,17 @@ import * as path from 'path';
import * as program from 'commander';
import * as os from 'os';
import * as fs from 'fs';
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 { runServer, printApiJson, installBrowsers } from './driver';
import { showTraceViewer } from './traceViewer/traceViewer';
import type { Browser, BrowserContext, Page, BrowserType, BrowserContextOptions, LaunchOptions } from '../..';
import * as playwright from '../..';
import { BrowserContext } from '../client/browserContext';
import { Browser } from '../client/browser';
import { Page } from '../client/page';
import { BrowserType } from '../client/browserType';
import { BrowserContextOptions, LaunchOptions } from '../client/types';
import { RecorderOutput, RecorderSupplement } from '../client/supplements/recorderSupplement';
import { ConsoleApiSupplement } from '../client/supplements/consoleApiSupplement';
import { FileOutput, OutputMultiplexer, TerminalOutput } from '../client/supplements/recorderOutputs';
program
.version('Version ' + require('../../package.json').version)
@ -317,36 +318,31 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi
async function open(options: Options, url: string | undefined) {
const { context } = await launchContext(options, false);
(context as any)._exposeConsoleApi();
new ConsoleApiSupplement(context);
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 as any)._traceDir = path.join(process.cwd(), '.trace');
const outputs: CodeGeneratorOutput[] = [TerminalOutput.create(process.stdout, languageGenerator.highlighterType())];
async function codegen(options: Options, url: string | undefined, language: string, outputFile?: string) {
const { context, launchOptions, contextOptions } = await launchContext(options, false);
let highlighterType = language;
if (highlighterType === 'python-async')
highlighterType = 'python';
const outputs: RecorderOutput[] = [TerminalOutput.create(process.stdout, highlighterType)];
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)._exposeConsoleApi();
new ConsoleApiSupplement(context);
new RecorderSupplement(context,
language,
launchOptions,
contextOptions,
options.device,
options.saveStorage,
output);
await openPage(context, url);
if (process.env.PWCLI_EXIT_FOR_TEST)
await Promise.all(context.pages().map(p => p.close()));
@ -387,20 +383,23 @@ async function pdf(options: Options, captureOptions: CaptureOptions, url: string
await browser.close();
}
function lookupBrowserType(options: Options): BrowserType<Browser> {
function lookupBrowserType(options: Options): BrowserType {
let name = options.browser;
if (options.device) {
const device = playwright.devices[options.device];
name = device.defaultBrowserType;
}
let browserType: any;
switch (name) {
case 'chromium': return playwright.chromium!;
case 'webkit': return playwright.webkit!;
case 'firefox': return playwright.firefox!;
case 'cr': return playwright.chromium!;
case 'wk': return playwright.webkit!;
case 'ff': return playwright.firefox!;
case 'chromium': browserType = playwright.chromium; break;
case 'webkit': browserType = playwright.webkit; break;
case 'firefox': browserType = playwright.firefox; break;
case 'cr': browserType = playwright.chromium; break;
case 'wk': browserType = playwright.webkit; break;
case 'ff': browserType = playwright.firefox; break;
}
if (browserType)
return browserType;
program.help();
}

View File

@ -18,7 +18,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { installInspectorController } from '../server/inspector/inspectorController';
import { installInspectorController } from '../server/supplements/inspectorController';
import { DispatcherConnection } from '../dispatchers/dispatcher';
import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher';
import { installBrowsersWithProgressBar } from '../install/installer';

View File

@ -253,14 +253,6 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
throw e;
}
}
async _exposeConsoleApi() {
await this._channel.exposeConsoleApi();
}
async _enableRecorder<Arg>() {
await this._channel.enableRecorder();
}
}
export async function prepareBrowserContextOptions(options: BrowserContextOptions): Promise<channels.BrowserNewContextOptions> {

View File

@ -0,0 +1,23 @@
/**
* 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 { BrowserContext } from '../browserContext';
export class ConsoleApiSupplement {
constructor(context: BrowserContext) {
context._channel.consoleSupplementExpose().catch(e => {});
}
}

View File

@ -18,11 +18,11 @@ import * as fs from 'fs';
import * as querystring from 'querystring';
import { Writable } from 'stream';
import * as hljs from '../../third_party/highlightjs/highlightjs';
import { CodeGeneratorOutput } from './codeGenerator';
import { RecorderOutput } from './recorderSupplement';
export class OutputMultiplexer implements CodeGeneratorOutput {
private _outputs: CodeGeneratorOutput[]
constructor(outputs: CodeGeneratorOutput[]) {
export class OutputMultiplexer implements RecorderOutput {
private _outputs: RecorderOutput[]
constructor(outputs: RecorderOutput[]) {
this._outputs = outputs;
}
@ -58,7 +58,7 @@ export class BufferOutput {
}
}
export class FileOutput extends BufferOutput implements CodeGeneratorOutput {
export class FileOutput extends BufferOutput implements RecorderOutput {
private _fileName: string;
constructor(fileName: string) {
@ -71,7 +71,7 @@ export class FileOutput extends BufferOutput implements CodeGeneratorOutput {
}
}
export class TerminalOutput implements CodeGeneratorOutput {
export class TerminalOutput implements RecorderOutput {
private _output: Writable
private _language: string;
@ -127,7 +127,7 @@ export class TerminalOutput implements CodeGeneratorOutput {
flush() {}
}
export class FlushingTerminalOutput extends BufferOutput implements CodeGeneratorOutput {
export class FlushingTerminalOutput extends BufferOutput implements RecorderOutput {
private _output: Writable
constructor(output: Writable) {

View File

@ -0,0 +1,51 @@
/**
* 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 { BrowserContext } from '../browserContext';
import { BrowserContextOptions, LaunchOptions } from '../types';
export class RecorderSupplement {
constructor(context: BrowserContext,
language: string,
launchOptions: LaunchOptions,
contextOptions: BrowserContextOptions,
device: string | undefined,
saveStorage: string | undefined,
output: RecorderOutput) {
if (process.env.PWTRACE)
contextOptions._traceDir = path.join(process.cwd(), '.trace');
context._channel.on('recorderSupplementPrintLn', event => output.printLn(event.text));
context._channel.on('recorderSupplementPopLn', event => output.popLn(event.text));
context.on('close', () => output.flush());
context._channel.recorderSupplementEnable({
language,
launchOptions,
contextOptions,
device,
saveStorage,
}).catch(e => {});
}
}
export interface RecorderOutput {
printLn(text: string): void;
popLn(text: string): void;
flush(): void;
}

View File

@ -21,6 +21,8 @@ import * as channels from '../protocol/channels';
import { RouteDispatcher, RequestDispatcher } from './networkDispatchers';
import { CRBrowserContext } from '../server/chromium/crBrowser';
import { CDPSessionDispatcher } from './cdpSessionDispatcher';
import { RecorderSupplement } from '../server/supplements/recorderSupplement';
import { ConsoleApiSupplement } from '../server/supplements/consoleApiSupplement';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextInitializer> implements channels.BrowserContextChannel {
private _context: BrowserContext;
@ -125,12 +127,17 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
await this._context.close();
}
async exposeConsoleApi(): Promise<void> {
await this._context.exposeConsoleApi();
async consoleSupplementExpose(): Promise<void> {
const consoleApi = new ConsoleApiSupplement(this._context);
await consoleApi.install();
}
async enableRecorder(): Promise<void> {
await this._context.enableRecorder();
async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> {
const recorder = new RecorderSupplement(this._context, params, {
printLn: text => this._dispatchEvent('recorderSupplementPrintLn', { text }),
popLn: text => this._dispatchEvent('recorderSupplementPopLn', { text }),
});
await recorder.install();
}
async crNewCDPSession(params: channels.BrowserContextCrNewCDPSessionParams): Promise<channels.BrowserContextCrNewCDPSessionResult> {

View File

@ -20,7 +20,7 @@ import type { Playwright as PlaywrightAPI } from './client/playwright';
import { PlaywrightDispatcher } from './dispatchers/playwrightDispatcher';
import { Connection } from './client/connection';
import { BrowserServerLauncherImpl } from './browserServerImpl';
import { installInspectorController } from './server/inspector/inspectorController';
import { installInspectorController } from './server/supplements/inspectorController';
import { installTracer } from './trace/tracer';
import { installHarTracer } from './trace/harTracer';
import * as path from 'path';

View File

@ -535,6 +535,8 @@ export interface BrowserContextChannel extends Channel {
on(event: 'route', callback: (params: BrowserContextRouteEvent) => void): this;
on(event: 'crBackgroundPage', callback: (params: BrowserContextCrBackgroundPageEvent) => void): this;
on(event: 'crServiceWorker', callback: (params: BrowserContextCrServiceWorkerEvent) => void): this;
on(event: 'recorderSupplementPrintLn', callback: (params: BrowserContextRecorderSupplementPrintLnEvent) => void): this;
on(event: 'recorderSupplementPopLn', callback: (params: BrowserContextRecorderSupplementPopLnEvent) => void): this;
addCookies(params: BrowserContextAddCookiesParams, metadata?: Metadata): Promise<BrowserContextAddCookiesResult>;
addInitScript(params: BrowserContextAddInitScriptParams, metadata?: Metadata): Promise<BrowserContextAddInitScriptResult>;
clearCookies(params?: BrowserContextClearCookiesParams, metadata?: Metadata): Promise<BrowserContextClearCookiesResult>;
@ -552,8 +554,8 @@ export interface BrowserContextChannel extends Channel {
setNetworkInterceptionEnabled(params: BrowserContextSetNetworkInterceptionEnabledParams, metadata?: Metadata): Promise<BrowserContextSetNetworkInterceptionEnabledResult>;
setOffline(params: BrowserContextSetOfflineParams, metadata?: Metadata): Promise<BrowserContextSetOfflineResult>;
storageState(params?: BrowserContextStorageStateParams, metadata?: Metadata): Promise<BrowserContextStorageStateResult>;
exposeConsoleApi(params?: BrowserContextExposeConsoleApiParams, metadata?: Metadata): Promise<BrowserContextExposeConsoleApiResult>;
enableRecorder(params?: BrowserContextEnableRecorderParams, metadata?: Metadata): Promise<BrowserContextEnableRecorderResult>;
consoleSupplementExpose(params?: BrowserContextConsoleSupplementExposeParams, metadata?: Metadata): Promise<BrowserContextConsoleSupplementExposeResult>;
recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: Metadata): Promise<BrowserContextRecorderSupplementEnableResult>;
crNewCDPSession(params: BrowserContextCrNewCDPSessionParams, metadata?: Metadata): Promise<BrowserContextCrNewCDPSessionResult>;
}
export type BrowserContextBindingCallEvent = {
@ -573,6 +575,12 @@ export type BrowserContextCrBackgroundPageEvent = {
export type BrowserContextCrServiceWorkerEvent = {
worker: WorkerChannel,
};
export type BrowserContextRecorderSupplementPrintLnEvent = {
text: string,
};
export type BrowserContextRecorderSupplementPopLnEvent = {
text: string,
};
export type BrowserContextAddCookiesParams = {
cookies: SetNetworkCookie[],
};
@ -695,12 +703,21 @@ export type BrowserContextStorageStateResult = {
cookies: NetworkCookie[],
origins: OriginStorage[],
};
export type BrowserContextExposeConsoleApiParams = {};
export type BrowserContextExposeConsoleApiOptions = {};
export type BrowserContextExposeConsoleApiResult = void;
export type BrowserContextEnableRecorderParams = {};
export type BrowserContextEnableRecorderOptions = {};
export type BrowserContextEnableRecorderResult = void;
export type BrowserContextConsoleSupplementExposeParams = {};
export type BrowserContextConsoleSupplementExposeOptions = {};
export type BrowserContextConsoleSupplementExposeResult = void;
export type BrowserContextRecorderSupplementEnableParams = {
language: string,
launchOptions: any,
contextOptions: any,
device?: string,
saveStorage?: string,
};
export type BrowserContextRecorderSupplementEnableOptions = {
device?: string,
saveStorage?: string,
};
export type BrowserContextRecorderSupplementEnableResult = void;
export type BrowserContextCrNewCDPSessionParams = {
page: PageChannel,
};

View File

@ -599,11 +599,17 @@ BrowserContext:
type: array
items: OriginStorage
exposeConsoleApi:
consoleSupplementExpose:
experimental: True
enableRecorder:
recorderSupplementEnable:
experimental: True
parameters:
language: string
launchOptions: json
contextOptions: json
device: string?
saveStorage: string?
crNewCDPSession:
parameters:
@ -636,7 +642,13 @@ BrowserContext:
parameters:
worker: Worker
recorderSupplementPrintLn:
parameters:
text: string
recorderSupplementPopLn:
parameters:
text: string
Page:
type: interface

View File

@ -335,8 +335,14 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
offline: tBoolean,
});
scheme.BrowserContextStorageStateParams = tOptional(tObject({}));
scheme.BrowserContextExposeConsoleApiParams = tOptional(tObject({}));
scheme.BrowserContextEnableRecorderParams = tOptional(tObject({}));
scheme.BrowserContextConsoleSupplementExposeParams = tOptional(tObject({}));
scheme.BrowserContextRecorderSupplementEnableParams = tObject({
language: tString,
launchOptions: tAny,
contextOptions: tAny,
device: tOptional(tString),
saveStorage: tOptional(tString),
});
scheme.BrowserContextCrNewCDPSessionParams = tObject({
page: tChannel('Page'),
});

View File

@ -17,7 +17,7 @@
import * as debug from 'debug';
import * as http from 'http';
import * as WebSocket from 'ws';
import { installInspectorController } from '../server/inspector/inspectorController';
import { installInspectorController } from '../server/supplements/inspectorController';
import { DispatcherConnection } from '../dispatchers/dispatcher';
import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher';
import { Playwright } from '../server/playwright';

View File

@ -19,8 +19,6 @@ import { EventEmitter } from 'events';
import { TimeoutSettings } from '../utils/timeoutSettings';
import { mkdirIfNeeded } from '../utils/utils';
import { Browser, BrowserOptions } from './browser';
import * as consoleApiSource from '../generated/consoleApiSource';
import * as recorderSource from '../generated/recorderSource';
import * as dom from './dom';
import { Download } from './download';
import * as frames from './frames';
@ -381,15 +379,7 @@ export abstract class BrowserContext extends EventEmitter {
}
}
async exposeConsoleApi() {
await this._extendInjectedScript(consoleApiSource.source);
}
async enableRecorder() {
await this._extendInjectedScript(recorderSource.source);
}
private async _extendInjectedScript(source: string) {
async extendInjectedScript(source: string) {
const installInFrame = (frame: frames.Frame) => frame.extendInjectedScript(source).catch(e => {});
const installInPage = (page: Page) => {
page.on(Page.Events.InternalFrameNavigatedToNewDocument, installInFrame);

View File

@ -0,0 +1,30 @@
/**
* 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 consoleApiSource from '../../generated/consoleApiSource';
import { BrowserContext } from '../browserContext';
export class ConsoleApiSupplement {
private _context: BrowserContext;
constructor(context: BrowserContext) {
this._context = context;
}
async install() {
await this._context.extendInjectedScript(consoleApiSource.source);
}
}

View File

@ -14,16 +14,16 @@
* limitations under the License.
*/
import type * as actions from '../../../cli/codegen/recorderActions';
import type * as actions from '../recorder/recorderActions';
import type InjectedScript from '../../injected/injectedScript';
import { generateSelector } from './selectorGenerator';
import { html } from './html';
declare global {
interface Window {
performPlaywrightAction: (action: actions.Action) => Promise<void>;
recordPlaywrightAction: (action: actions.Action) => Promise<void>;
commitLastAction: () => Promise<void>;
playwrightRecorderPerformAction: (action: actions.Action) => Promise<void>;
playwrightRecorderRecordAction: (action: actions.Action) => Promise<void>;
playwrightRecorderCommitAction: () => Promise<void>;
}
}
@ -238,7 +238,7 @@ export class Recorder {
const { selector, elements } = generateSelector(this._injectedScript, hoveredElement);
if ((this._hoveredModel && this._hoveredModel.selector === selector) || this._hoveredElement !== hoveredElement)
return;
window.commitLastAction();
window.playwrightRecorderCommitAction();
this._hoveredModel = selector ? { selector, elements } : null;
this._updateHighlight();
if ((window as any)._highlightUpdatedForTest)
@ -331,7 +331,7 @@ export class Recorder {
}
if (elementType === 'file') {
window.recordPlaywrightAction({
window.playwrightRecorderRecordAction({
name: 'setInputFiles',
selector: this._activeModel!.selector,
signals: [],
@ -343,7 +343,7 @@ export class Recorder {
// Non-navigating actions are simply recorded by Playwright.
if (this._consumedDueWrongTarget(event))
return;
window.recordPlaywrightAction({
window.playwrightRecorderRecordAction({
name: 'fill',
selector: this._activeModel!.selector,
signals: [],
@ -434,7 +434,7 @@ export class Recorder {
private async _performAction(action: actions.Action) {
this._performingAction = true;
await window.performPlaywrightAction(action);
await window.playwrightRecorderPerformAction(action);
this._performingAction = false;
// Action could have changed DOM, update hovered model selectors.

View File

@ -16,6 +16,7 @@
import { BrowserContext, ContextListener, contextListeners } from '../browserContext';
import { isDebugMode } from '../../utils/utils';
import { ConsoleApiSupplement } from './consoleApiSupplement';
export function installInspectorController() {
contextListeners.add(new InspectorController());
@ -23,8 +24,10 @@ export function installInspectorController() {
class InspectorController implements ContextListener {
async onContextCreated(context: BrowserContext): Promise<void> {
if (isDebugMode())
context.exposeConsoleApi();
if (isDebugMode()) {
const consoleApi = new ConsoleApiSupplement(context);
await consoleApi.install();
}
}
async onContextWillDestroy(context: BrowserContext): Promise<void> {}
async onContextDidDestroy(context: BrowserContext): Promise<void> {}

View File

@ -14,8 +14,9 @@
* limitations under the License.
*/
import type { LaunchOptions, Frame, BrowserContextOptions } from '../../..';
import { LanguageGenerator } from './languages';
import type { BrowserContextOptions, LaunchOptions } from '../../../..';
import { Frame } from '../../frames';
import { LanguageGenerator } from './language';
import { Action, Signal } from './recorderActions';
export type ActionInContext = {
@ -28,7 +29,6 @@ export type ActionInContext = {
export interface CodeGeneratorOutput {
printLn(text: string): void;
popLn(text: string): void;
flush(): void;
}
export class CodeGenerator {
@ -50,10 +50,6 @@ export class CodeGenerator {
this._output.printLn(this._footerText);
}
exit() {
this._output.flush();
}
addAction(action: ActionInContext) {
this.willPerformAction(action);
this.didPerformAction(action);

View File

@ -15,18 +15,14 @@
*/
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';
import { LanguageGenerator, sanitizeDeviceOptions } from './language';
import { ActionInContext } from './codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from './recorderActions';
import { MouseClickOptions, toModifiers } from './utils';
import deviceDescriptors = require('../../deviceDescriptors');
export class CSharpLanguageGenerator implements LanguageGenerator {
highlighterType(): HighlighterType {
return 'csharp';
}
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
const { action, pageAlias, frame } = actionInContext;
const formatter = new CSharpFormatter(0);
@ -240,16 +236,10 @@ function toPascal(value: string): string {
}
function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string {
const device = deviceName && playwright.devices[deviceName];
const device = deviceName && deviceDescriptors[deviceName];
if (!device)
return formatArgs(options);
// Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {};
for (const property in options) {
if ((device as any)[property] !== (options as any)[property])
cleanedOptions[property] = (options as any)[property];
}
const serializedObject = formatObject(cleanedOptions, ' ');
const serializedObject = formatObject(sanitizeDeviceOptions(device, options), ' ');
// When there are no additional context options, we still want to spread the device inside.
if (!serializedObject)

View File

@ -15,18 +15,14 @@
*/
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';
import { LanguageGenerator, sanitizeDeviceOptions } from './language';
import { ActionInContext } from './codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from './recorderActions';
import { MouseClickOptions, toModifiers } from './utils';
import deviceDescriptors = require('../../deviceDescriptors');
export class JavaScriptLanguageGenerator implements LanguageGenerator {
highlighterType(): HighlighterType {
return 'javascript';
}
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
const { action, pageAlias, frame } = actionInContext;
const formatter = new JavaScriptFormatter(2);
@ -195,16 +191,11 @@ function formatObjectOrVoid(value: any, indent = ' '): string {
}
function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string {
const device = deviceName && playwright.devices[deviceName];
const device = deviceName && deviceDescriptors[deviceName];
if (!device)
return formatObjectOrVoid(options);
// Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {};
for (const property in options) {
if ((device as any)[property] !== (options as any)[property])
cleanedOptions[property] = (options as any)[property];
}
let serializedObject = formatObjectOrVoid(cleanedOptions);
let serializedObject = formatObjectOrVoid(sanitizeDeviceOptions(device, options));
// When there are no additional context options, we still want to spread the device inside.
if (!serializedObject)
serializedObject = '{\n}';

View File

@ -15,15 +15,20 @@
*/
import type { BrowserContextOptions, LaunchOptions } from '../../../..';
import { ActionInContext } from '../codeGenerator';
export type HighlighterType = 'javascript' | 'csharp' | 'python';
import { ActionInContext } from './codeGenerator';
export interface LanguageGenerator {
generateHeader(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName?: string): string;
generateAction(actionInContext: ActionInContext, performingAction: boolean): string;
generateFooter(saveStorage: string | undefined): string;
highlighterType(): HighlighterType;
}
export { JavaScriptLanguageGenerator } from './javascript';
export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions {
// Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {};
for (const property in options) {
if (JSON.stringify(device[property]) !== JSON.stringify((options as any)[property]))
cleanedOptions[property] = (options as any)[property];
}
return cleanedOptions;
}

View File

@ -15,11 +15,11 @@
*/
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';
import { LanguageGenerator, sanitizeDeviceOptions } from './language';
import { ActionInContext } from './codeGenerator';
import { actionTitle, NavigationSignal, PopupSignal, DownloadSignal, DialogSignal, Action } from './recorderActions';
import { MouseClickOptions, toModifiers } from './utils';
import deviceDescriptors = require('../../deviceDescriptors');
export class PythonLanguageGenerator implements LanguageGenerator {
private _awaitPrefix: '' | 'await ';
@ -32,10 +32,6 @@ export class PythonLanguageGenerator implements LanguageGenerator {
this._asyncPrefix = isAsync ? 'async ' : '';
}
highlighterType(): HighlighterType {
return 'python';
}
generateAction(actionInContext: ActionInContext, performingAction: boolean): string {
const { action, pageAlias, frame } = actionInContext;
const formatter = new PythonFormatter(4);
@ -217,16 +213,10 @@ function formatOptions(value: any, hasArguments: boolean): string {
}
function formatContextOptions(options: BrowserContextOptions, deviceName: string | undefined): string {
const device = deviceName && playwright.devices[deviceName];
const device = deviceName && deviceDescriptors[deviceName];
if (!device)
return formatOptions(options, false);
// Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {};
for (const property in options) {
if ((device as any)[property] !== (options as any)[property])
cleanedOptions[property] = (options as any)[property];
}
return `**playwright.devices["${deviceName}"]` + formatOptions(cleanedOptions, true);
return `**playwright.devices["${deviceName}"]` + formatOptions(sanitizeDeviceOptions(device, options), true);
}
class PythonFormatter {

View File

@ -14,10 +14,10 @@
* limitations under the License.
*/
import type { Page } from '../../..';
import { Frame } from '../../frames';
import * as actions from './recorderActions';
export type MouseClickOptions = Parameters<Page['click']>[1];
export type MouseClickOptions = Parameters<Frame['click']>[2];
export function toClickOptions(action: actions.ClickAction): { method: 'click' | 'dblclick', options: MouseClickOptions } {
let method: 'click' | 'dblclick' = 'click';

View File

@ -14,52 +14,74 @@
* 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 actions from './recorder/recorderActions';
import { CodeGenerator, ActionInContext, CodeGeneratorOutput } from './recorder/codeGenerator';
import { toClickOptions, toModifiers } from './recorder/utils';
import { Page } from '../page';
import { Frame } from '../frames';
import { BrowserContext } from '../browserContext';
import { LanguageGenerator } from './recorder/language';
import { JavaScriptLanguageGenerator } from './recorder/javascript';
import { CSharpLanguageGenerator } from './recorder/csharp';
import { PythonLanguageGenerator } from './recorder/python';
import { ProgressController } from '../progress';
import * as recorderSource from '../../generated/recorderSource';
type BindingSource = { frame: Frame, page: Page };
export class RecorderController {
export class RecorderSupplement {
private _generator: CodeGenerator;
private _pageAliases = new Map<Page, string>();
private _lastPopupOrdinal = 0;
private _lastDialogOrdinal = 0;
private _timers = new Set<NodeJS.Timeout>();
private _context: BrowserContext;
constructor(context: BrowserContext, generator: CodeGenerator) {
(context as any)._enableRecorder();
constructor(context: BrowserContext, params: { language: string, launchOptions: any, contextOptions: any, device?: string, saveStorage?: string}, output: CodeGeneratorOutput) {
this._context = context;
let languageGenerator: LanguageGenerator;
switch (params.language) {
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break;
case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break;
case 'python':
case 'python-async': languageGenerator = new PythonLanguageGenerator(params.language === 'python-async'); break;
default: throw new Error(`Invalid target: '${params.language}'`);
}
const generator = new CodeGenerator(context._browser._options.name, params.launchOptions, params.contextOptions, output, languageGenerator, params.device, params.saveStorage);
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())
async install() {
this._context.on('page', page => this._onPage(page));
for (const page of this._context.pages())
this._onPage(page);
context.once('close', () => {
this._context.once('close', () => {
for (const timer of this._timers)
clearTimeout(timer);
this._timers.clear();
this._generator.exit();
});
// Input actions that potentially lead to navigation are intercepted on the page and are
// performed by the Playwright.
await this._context.exposeBinding('playwrightRecorderPerformAction', false,
(source: BindingSource, action: actions.Action) => this._performAction(source.frame, action));
// Other non-essential actions are simply being recorded.
await this._context.exposeBinding('playwrightRecorderRecordAction', false,
(source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action));
// Commits last action so that no further signals are added to it.
await this._context.exposeBinding('playwrightRecorderCommitAction', false,
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction());
await this._context.extendInjectedScript(recorderSource.source);
}
private async _onPage(page: Page) {
// First page is called page, others are called popup1, popup2, etc.
const frame = page.mainFrame();
page.on('close', () => {
this._pageAliases.delete(page);
this._generator.addAction({
@ -72,10 +94,10 @@ export class RecorderController {
}
});
});
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));
frame.on(Frame.Events.Navigation, () => this._onFrameNavigated(frame, page));
page.on(Page.Events.Download, () => this._onDownload(page));
page.on(Page.Events.Popup, popup => this._onPopup(page, popup));
page.on(Page.Events.Dialog, () => this._onDialog(page));
const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : '';
const pageAlias = 'page' + suffix;
this._pageAliases.set(page, pageAlias);
@ -91,7 +113,7 @@ export class RecorderController {
committed: true,
action: {
name: 'openPage',
url: page.url(),
url: page.mainFrame().url(),
signals: [],
}
});
@ -99,7 +121,8 @@ export class RecorderController {
}
private async _performAction(frame: Frame, action: actions.Action) {
const page = frame.page();
const page = frame._page;
const controller = new ProgressController();
const actionInContext: ActionInContext = {
pageAlias: this._pageAliases.get(page)!,
frame,
@ -108,19 +131,19 @@ export class RecorderController {
this._generator.willPerformAction(actionInContext);
if (action.name === 'click') {
const { options } = toClickOptions(action);
await frame.click(action.selector, options);
await frame.click(controller, action.selector, options);
}
if (action.name === 'press') {
const modifiers = toModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
await frame.press(action.selector, shortcut);
await frame.press(controller, action.selector, shortcut);
}
if (action.name === 'check')
await frame.check(action.selector);
await frame.check(controller, action.selector);
if (action.name === 'uncheck')
await frame.uncheck(action.selector);
await frame.uncheck(controller, action.selector);
if (action.name === 'select')
await frame.selectOption(action.selector, action.options);
await frame.selectOption(controller, action.selector, [], action.options.map(value => ({ value })));
const timer = setTimeout(() => {
actionInContext.committed = true;
this._timers.delete(timer);
@ -132,15 +155,13 @@ export class RecorderController {
private async _recordAction(frame: Frame, action: actions.Action) {
// We are lacking frame.page() in
this._generator.addAction({
pageAlias: this._pageAliases.get(frame.page())!,
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() });
}
@ -150,12 +171,12 @@ export class RecorderController {
const popupAlias = this._pageAliases.get(popup)!;
this._generator.signal(pageAlias, page.mainFrame(), { name: 'popup', popupAlias });
}
private _onDownload(page: Page, download: Download) {
private _onDownload(page: Page) {
const pageAlias = this._pageAliases.get(page)!;
this._generator.signal(pageAlias, page.mainFrame(), { name: 'download' });
}
private _onDialog(page: Page, dialog: Dialog) {
private _onDialog(page: Page) {
const pageAlias = this._pageAliases.get(page)!;
this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) });
}

View File

@ -235,8 +235,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
recorder.waitForOutput('check'),
page.click('input')
]);
await recorder.waitForOutput('check');
expect(recorder.output()).toContain(`
await recorder.waitForOutput(`
// Check input[name="accept"]
await page.check('input[name="accept"]');`);
expect(message.text()).toBe('true');
@ -253,8 +252,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
recorder.waitForOutput('check'),
page.keyboard.press('Space')
]);
await recorder.waitForOutput('check');
expect(recorder.output()).toContain(`
await recorder.waitForOutput(`
// Check input[name="accept"]
await page.check('input[name="accept"]');`);
expect(message.text()).toBe('true');
@ -321,7 +319,6 @@ describe('cli codegen', (test, { browserName, headful }) => {
const selector = await recorder.hoverOverElement('a');
expect(selector).toBe('text="link"');
await Promise.all([
page.waitForNavigation(),
recorder.waitForOutput('assert'),
@ -398,8 +395,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
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(`
await recorder.waitForOutput(`
// Upload file-to-upload.txt
await page.setInputFiles('input[type="file"]', 'file-to-upload.txt');`);
});
@ -415,8 +411,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
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(`
await recorder.waitForOutput(`
// 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']);`);
});
@ -432,13 +427,14 @@ describe('cli codegen', (test, { browserName, headful }) => {
await page.setInputFiles('input[type=file]', []);
await page.click('input[type=file]');
await recorder.waitForOutput('setInputFiles');
expect(recorder.output()).toContain(`
await recorder.waitForOutput(`
// Clear selected files
await page.setInputFiles('input[type="file"]', []);`);
});
it('should download files', async ({ page, recorder, httpServer }) => {
it('should download files', (test, {browserName}) => {
test.fixme(browserName === 'webkit', 'Generated page.waitForNavigation next to page.waitForEvent(download)');
}, async ({ page, recorder, httpServer }) => {
httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => {
const pathName = url.parse(req.url!).path;
if (pathName === '/download') {
@ -458,8 +454,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
page.waitForEvent('download'),
page.click('text=Download')
]);
await recorder.waitForOutput('page.click');
expect(recorder.output()).toContain(`
await recorder.waitForOutput(`
// Click text="Download"
const [download] = await Promise.all([
page.waitForEvent('download'),
@ -476,8 +471,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
await dialog.dismiss();
});
await page.click('text="click me"');
await recorder.waitForOutput('page.once');
expect(recorder.output()).toContain(`
await recorder.waitForOutput(`
// Click text="click me"
page.once('dialog', dialog => {
console.log(\`Dialog message: $\{dialog.message()}\`);
@ -500,8 +494,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
</script>`, httpServer.PREFIX);
for (let i = 1; i < 3; ++i) {
await page.evaluate('pushState()');
await recorder.waitForOutput(`seqNum=${i}`);
expect(recorder.output()).toContain(`await page.goto('${httpServer.PREFIX}/#seqNum=${i}');`);
await recorder.waitForOutput(`await page.goto('${httpServer.PREFIX}/#seqNum=${i}');`);
}
});

View File

@ -21,10 +21,8 @@ import { ChildProcess, spawn } from 'child_process';
import { folio as baseFolio } from '../fixtures';
import type { Page, BrowserType, Browser, BrowserContext } from '../..';
export { config } from 'folio';
import { RecorderController } from '../../src/cli/codegen/recorderController';
import { TerminalOutput } from '../../src/cli/codegen/outputs';
import { JavaScriptLanguageGenerator } from '../../src/cli/codegen/languages';
import { CodeGenerator } from '../../src/cli/codegen/codeGenerator';
import { FlushingTerminalOutput } from '../../lib/client/supplements/recorderOutputs';
import { RecorderSupplement } from '../../lib/client/supplements/recorderSupplement';
type WorkerFixtures = {
browserType: BrowserType<Browser>;
@ -41,12 +39,10 @@ type TestFixtures = {
export const fixtures = baseFolio.extend<TestFixtures, WorkerFixtures>();
fixtures.contextWrapper.init(async ({ browser }, runTest) => {
const context = await browser.newContext();
const context = await browser.newContext() as BrowserContext;
const outputBuffer = new WritableBuffer();
const output = new TerminalOutput(outputBuffer as any as Writable, 'javascript');
const languageGenerator = new JavaScriptLanguageGenerator();
const generator = new CodeGenerator('chromium', {}, {}, output, languageGenerator, undefined, undefined);
new RecorderController(context, generator);
const output = new FlushingTerminalOutput(outputBuffer as any as Writable);
new RecorderSupplement(context, 'javascript', {}, {}, undefined, undefined, output);
await runTest({ context, output: outputBuffer });
await context.close();
});

View File

@ -16,10 +16,11 @@
import { folio } from './fixtures';
import type { Page, Frame } from '..';
import { ConsoleApiSupplement } from '../lib/client/supplements/consoleApiSupplement';
const fixtures = folio.extend();
fixtures.context.override(async ({ context }, run) => {
await (context as any)._exposeConsoleApi();
new ConsoleApiSupplement(context);
await run(context);
});
const { describe, it, expect } = fixtures.build();

View File

@ -68,8 +68,8 @@ function runBuild() {
const webPackFiles = [
'src/server/injected/injectedScript.webpack.config.js',
'src/server/injected/utilityScript.webpack.config.js',
'src/server/inspector/injected/consoleApi.webpack.config.js',
'src/server/inspector/injected/recorder.webpack.config.js',
'src/server/supplements/injected/consoleApi.webpack.config.js',
'src/server/supplements/injected/recorder.webpack.config.js',
'src/cli/traceViewer/web/web.webpack.config.js',
];
for (const file of webPackFiles) {

View File

@ -125,13 +125,15 @@ DEPS['src/server/'] = [
// Can depend on any files in these subdirectories.
'src/server/common/**',
'src/server/injected/**',
'src/server/supplements/**',
];
// No dependencies for code shared between node and page.
DEPS['src/server/common/'] = [];
// Strict dependencies for injected code.
DEPS['src/server/injected/'] = ['src/server/common/'];
DEPS['src/server/inspector/injected/'] = ['src/server/common/', 'src/cli/codegen/', 'src/server/injected/'];
DEPS['src/client/supplements/'] = ['src/client/'];
// Electron and Clank use chromium internally.
DEPS['src/server/android/'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/protocol/'];
@ -144,7 +146,7 @@ DEPS['src/cli/driver.ts'] = DEPS['src/inprocess.ts'] = DEPS['src/browserServerIm
DEPS['src/trace/'] = ['src/utils/', 'src/client/**', 'src/server/**'];
// The service is a cross-cutting feature, and so it depends on a bunch of things.
DEPS['src/remote/'] = ['src/client/', 'src/debug/', 'src/dispatchers/', 'src/server/', 'src/server/inspector/', 'src/server/electron/', 'src/trace/'];
DEPS['src/remote/'] = ['src/client/', 'src/debug/', 'src/dispatchers/', 'src/server/', 'src/server/supplements/', 'src/server/electron/', 'src/trace/'];
DEPS['src/service.ts'] = ['src/remote/'];
// CLI should only use client-side features.