mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: introduce debug toolbar (#5145)
This commit is contained in:
parent
894abbfe28
commit
01d6f83597
@ -30,9 +30,6 @@ 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)
|
||||
@ -318,7 +315,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi
|
||||
|
||||
async function open(options: Options, url: string | undefined) {
|
||||
const { context } = await launchContext(options, false);
|
||||
new ConsoleApiSupplement(context);
|
||||
await context._enableConsoleApi();
|
||||
await openPage(context, url);
|
||||
if (process.env.PWCLI_EXIT_FOR_TEST)
|
||||
await Promise.all(context.pages().map(p => p.close()));
|
||||
@ -326,23 +323,9 @@ async function open(options: Options, url: string | undefined) {
|
||||
|
||||
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);
|
||||
|
||||
new ConsoleApiSupplement(context);
|
||||
new RecorderSupplement(context,
|
||||
language,
|
||||
launchOptions,
|
||||
contextOptions,
|
||||
options.device,
|
||||
options.saveStorage,
|
||||
output);
|
||||
|
||||
if (process.env.PWTRACE)
|
||||
contextOptions._traceDir = path.join(process.cwd(), '.trace');
|
||||
await context._enableRecorder(language, launchOptions, contextOptions, options.device, options.saveStorage, !!process.stdout.columns, outputFile ? path.resolve(outputFile) : undefined);
|
||||
await openPage(context, url);
|
||||
if (process.env.PWCLI_EXIT_FOR_TEST)
|
||||
await Promise.all(context.pages().map(p => p.close()));
|
||||
|
@ -26,7 +26,7 @@ import { Browser } from './browser';
|
||||
import { Events } from './events';
|
||||
import { TimeoutSettings } from '../utils/timeoutSettings';
|
||||
import { Waiter } from './waiter';
|
||||
import { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions, StorageState } from './types';
|
||||
import { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions } from './types';
|
||||
import { isUnderTest, headersObjectToArray, mkdirIfNeeded } from '../utils/utils';
|
||||
import { isSafeCloseError } from '../utils/errors';
|
||||
import * as api from '../../types/types';
|
||||
@ -44,6 +44,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
|
||||
_ownerPage: Page | undefined;
|
||||
private _closedPromise: Promise<void>;
|
||||
_options: channels.BrowserNewContextParams = {};
|
||||
private _stdout: NodeJS.WriteStream;
|
||||
private _stderr: NodeJS.WriteStream;
|
||||
|
||||
static from(context: channels.BrowserContextChannel): BrowserContext {
|
||||
return (context as any)._object;
|
||||
@ -62,6 +64,10 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
|
||||
this._channel.on('close', () => this._onClose());
|
||||
this._channel.on('page', ({page}) => this._onPage(Page.from(page)));
|
||||
this._channel.on('route', ({ route, request }) => this._onRoute(network.Route.from(route), network.Request.from(request)));
|
||||
this._stdout = process.stdout;
|
||||
this._stderr = process.stderr;
|
||||
this._channel.on('stdout', ({ data }) => this._stdout.write(Buffer.from(data, 'base64')));
|
||||
this._channel.on('stderr', ({ data }) => this._stderr.write(Buffer.from(data, 'base64')));
|
||||
this._closedPromise = new Promise(f => this.once(Events.BrowserContext.Close, f));
|
||||
}
|
||||
|
||||
@ -253,6 +259,35 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async _pause() {
|
||||
return this._wrapApiCall('browserContext.pause', async () => {
|
||||
await this._channel.pause();
|
||||
});
|
||||
}
|
||||
|
||||
async _enableConsoleApi() {
|
||||
await this._channel.consoleSupplementExpose();
|
||||
}
|
||||
|
||||
async _enableRecorder(
|
||||
language: string,
|
||||
launchOptions?: LaunchOptions,
|
||||
contextOptions?: BrowserContextOptions,
|
||||
device?: string,
|
||||
saveStorage?: string,
|
||||
terminal?: boolean,
|
||||
outputFile?: string) {
|
||||
await this._channel.recorderSupplementEnable({
|
||||
language,
|
||||
launchOptions,
|
||||
contextOptions,
|
||||
device,
|
||||
saveStorage,
|
||||
terminal,
|
||||
outputFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareBrowserContextOptions(options: BrowserContextOptions): Promise<channels.BrowserNewContextOptions> {
|
||||
|
@ -640,9 +640,7 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
|
||||
}
|
||||
|
||||
async _pause() {
|
||||
return this._wrapApiCall('page.pause', async () => {
|
||||
await this._channel.pause();
|
||||
});
|
||||
await this.context()._pause();
|
||||
}
|
||||
|
||||
async _pdf(options: PDFOptions = {}): Promise<Buffer> {
|
||||
|
@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BrowserContext } from '../browserContext';
|
||||
|
||||
export class ConsoleApiSupplement {
|
||||
constructor(context: BrowserContext) {
|
||||
context._channel.consoleSupplementExpose().catch(e => {});
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * 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;
|
||||
}
|
@ -38,6 +38,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
|
||||
this._dispatchEvent('close');
|
||||
this._dispose();
|
||||
});
|
||||
context.on(BrowserContext.Events.StdOut, data => this._dispatchEvent('stdout', { data: Buffer.from(data, 'utf8').toString('base64') }));
|
||||
context.on(BrowserContext.Events.StdErr, data => this._dispatchEvent('stderr', { data: Buffer.from(data, 'utf8').toString('base64') }));
|
||||
|
||||
if (context._browser._options.name === 'chromium') {
|
||||
for (const page of (context as CRBrowserContext).backgroundPages())
|
||||
@ -133,11 +135,17 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
|
||||
}
|
||||
|
||||
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 RecorderSupplement.getOrCreate(this._context, 'codegen', params);
|
||||
}
|
||||
|
||||
async pause() {
|
||||
if (!this._context._browser._options.headful)
|
||||
return;
|
||||
const recorder = await RecorderSupplement.getOrCreate(this._context, 'pause', {
|
||||
language: 'javascript',
|
||||
terminal: true
|
||||
});
|
||||
await recorder.install();
|
||||
await recorder.pause();
|
||||
}
|
||||
|
||||
async crNewCDPSession(params: channels.BrowserContextCrNewCDPSessionParams): Promise<channels.BrowserContextCrNewCDPSessionResult> {
|
||||
|
@ -237,10 +237,6 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> i
|
||||
return { entries: await coverage.stopCSSCoverage() };
|
||||
}
|
||||
|
||||
async pause() {
|
||||
await this._page.pause();
|
||||
}
|
||||
|
||||
_onFrameAttached(frame: Frame) {
|
||||
this._dispatchEvent('frameAttached', { frame: FrameDispatcher.from(this._scope, frame) });
|
||||
}
|
||||
|
@ -533,10 +533,10 @@ export interface BrowserContextChannel extends Channel {
|
||||
on(event: 'close', callback: (params: BrowserContextCloseEvent) => void): this;
|
||||
on(event: 'page', callback: (params: BrowserContextPageEvent) => void): this;
|
||||
on(event: 'route', callback: (params: BrowserContextRouteEvent) => void): this;
|
||||
on(event: 'stdout', callback: (params: BrowserContextStdoutEvent) => void): this;
|
||||
on(event: 'stderr', callback: (params: BrowserContextStderrEvent) => 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>;
|
||||
@ -555,6 +555,7 @@ export interface BrowserContextChannel extends Channel {
|
||||
setOffline(params: BrowserContextSetOfflineParams, metadata?: Metadata): Promise<BrowserContextSetOfflineResult>;
|
||||
storageState(params?: BrowserContextStorageStateParams, metadata?: Metadata): Promise<BrowserContextStorageStateResult>;
|
||||
consoleSupplementExpose(params?: BrowserContextConsoleSupplementExposeParams, metadata?: Metadata): Promise<BrowserContextConsoleSupplementExposeResult>;
|
||||
pause(params?: BrowserContextPauseParams, metadata?: Metadata): Promise<BrowserContextPauseResult>;
|
||||
recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: Metadata): Promise<BrowserContextRecorderSupplementEnableResult>;
|
||||
crNewCDPSession(params: BrowserContextCrNewCDPSessionParams, metadata?: Metadata): Promise<BrowserContextCrNewCDPSessionResult>;
|
||||
}
|
||||
@ -569,18 +570,18 @@ export type BrowserContextRouteEvent = {
|
||||
route: RouteChannel,
|
||||
request: RequestChannel,
|
||||
};
|
||||
export type BrowserContextStdoutEvent = {
|
||||
data: Binary,
|
||||
};
|
||||
export type BrowserContextStderrEvent = {
|
||||
data: Binary,
|
||||
};
|
||||
export type BrowserContextCrBackgroundPageEvent = {
|
||||
page: PageChannel,
|
||||
};
|
||||
export type BrowserContextCrServiceWorkerEvent = {
|
||||
worker: WorkerChannel,
|
||||
};
|
||||
export type BrowserContextRecorderSupplementPrintLnEvent = {
|
||||
text: string,
|
||||
};
|
||||
export type BrowserContextRecorderSupplementPopLnEvent = {
|
||||
text: string,
|
||||
};
|
||||
export type BrowserContextAddCookiesParams = {
|
||||
cookies: SetNetworkCookie[],
|
||||
};
|
||||
@ -706,16 +707,25 @@ export type BrowserContextStorageStateResult = {
|
||||
export type BrowserContextConsoleSupplementExposeParams = {};
|
||||
export type BrowserContextConsoleSupplementExposeOptions = {};
|
||||
export type BrowserContextConsoleSupplementExposeResult = void;
|
||||
export type BrowserContextPauseParams = {};
|
||||
export type BrowserContextPauseOptions = {};
|
||||
export type BrowserContextPauseResult = void;
|
||||
export type BrowserContextRecorderSupplementEnableParams = {
|
||||
language: string,
|
||||
launchOptions: any,
|
||||
contextOptions: any,
|
||||
launchOptions?: any,
|
||||
contextOptions?: any,
|
||||
device?: string,
|
||||
saveStorage?: string,
|
||||
terminal?: boolean,
|
||||
outputFile?: string,
|
||||
};
|
||||
export type BrowserContextRecorderSupplementEnableOptions = {
|
||||
launchOptions?: any,
|
||||
contextOptions?: any,
|
||||
device?: string,
|
||||
saveStorage?: string,
|
||||
terminal?: boolean,
|
||||
outputFile?: string,
|
||||
};
|
||||
export type BrowserContextRecorderSupplementEnableResult = void;
|
||||
export type BrowserContextCrNewCDPSessionParams = {
|
||||
@ -786,7 +796,6 @@ export interface PageChannel extends Channel {
|
||||
mouseClick(params: PageMouseClickParams, metadata?: Metadata): Promise<PageMouseClickResult>;
|
||||
touchscreenTap(params: PageTouchscreenTapParams, metadata?: Metadata): Promise<PageTouchscreenTapResult>;
|
||||
accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: Metadata): Promise<PageAccessibilitySnapshotResult>;
|
||||
pause(params?: PagePauseParams, metadata?: Metadata): Promise<PagePauseResult>;
|
||||
pdf(params: PagePdfParams, metadata?: Metadata): Promise<PagePdfResult>;
|
||||
crStartJSCoverage(params: PageCrStartJSCoverageParams, metadata?: Metadata): Promise<PageCrStartJSCoverageResult>;
|
||||
crStopJSCoverage(params?: PageCrStopJSCoverageParams, metadata?: Metadata): Promise<PageCrStopJSCoverageResult>;
|
||||
@ -1083,9 +1092,6 @@ export type PageAccessibilitySnapshotOptions = {
|
||||
export type PageAccessibilitySnapshotResult = {
|
||||
rootAXNode?: AXNode,
|
||||
};
|
||||
export type PagePauseParams = {};
|
||||
export type PagePauseOptions = {};
|
||||
export type PagePauseResult = void;
|
||||
export type PagePdfParams = {
|
||||
scale?: number,
|
||||
displayHeaderFooter?: boolean,
|
||||
|
@ -602,14 +602,19 @@ BrowserContext:
|
||||
consoleSupplementExpose:
|
||||
experimental: True
|
||||
|
||||
pause:
|
||||
experimental: True
|
||||
|
||||
recorderSupplementEnable:
|
||||
experimental: True
|
||||
parameters:
|
||||
language: string
|
||||
launchOptions: json
|
||||
contextOptions: json
|
||||
launchOptions: json?
|
||||
contextOptions: json?
|
||||
device: string?
|
||||
saveStorage: string?
|
||||
terminal: boolean?
|
||||
outputFile: string?
|
||||
|
||||
crNewCDPSession:
|
||||
parameters:
|
||||
@ -634,6 +639,14 @@ BrowserContext:
|
||||
route: Route
|
||||
request: Request
|
||||
|
||||
stdout:
|
||||
parameters:
|
||||
data: binary
|
||||
|
||||
stderr:
|
||||
parameters:
|
||||
data: binary
|
||||
|
||||
crBackgroundPage:
|
||||
parameters:
|
||||
page: Page
|
||||
@ -642,14 +655,6 @@ BrowserContext:
|
||||
parameters:
|
||||
worker: Worker
|
||||
|
||||
recorderSupplementPrintLn:
|
||||
parameters:
|
||||
text: string
|
||||
|
||||
recorderSupplementPopLn:
|
||||
parameters:
|
||||
text: string
|
||||
|
||||
Page:
|
||||
type: interface
|
||||
|
||||
@ -854,8 +859,6 @@ Page:
|
||||
returns:
|
||||
rootAXNode: AXNode?
|
||||
|
||||
pause:
|
||||
|
||||
pdf:
|
||||
parameters:
|
||||
scale: number?
|
||||
|
@ -336,12 +336,15 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
|
||||
});
|
||||
scheme.BrowserContextStorageStateParams = tOptional(tObject({}));
|
||||
scheme.BrowserContextConsoleSupplementExposeParams = tOptional(tObject({}));
|
||||
scheme.BrowserContextPauseParams = tOptional(tObject({}));
|
||||
scheme.BrowserContextRecorderSupplementEnableParams = tObject({
|
||||
language: tString,
|
||||
launchOptions: tAny,
|
||||
contextOptions: tAny,
|
||||
launchOptions: tOptional(tAny),
|
||||
contextOptions: tOptional(tAny),
|
||||
device: tOptional(tString),
|
||||
saveStorage: tOptional(tString),
|
||||
terminal: tOptional(tBoolean),
|
||||
outputFile: tOptional(tString),
|
||||
});
|
||||
scheme.BrowserContextCrNewCDPSessionParams = tObject({
|
||||
page: tChannel('Page'),
|
||||
@ -447,7 +450,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
|
||||
interestingOnly: tOptional(tBoolean),
|
||||
root: tOptional(tChannel('ElementHandle')),
|
||||
});
|
||||
scheme.PagePauseParams = tOptional(tObject({}));
|
||||
scheme.PagePdfParams = tObject({
|
||||
scale: tOptional(tNumber),
|
||||
displayHeaderFooter: tOptional(tBoolean),
|
||||
|
@ -93,6 +93,9 @@ export abstract class BrowserContext extends EventEmitter {
|
||||
Close: 'close',
|
||||
Page: 'page',
|
||||
VideoStarted: 'videostarted',
|
||||
BeforeClose: 'beforeclose',
|
||||
StdOut: 'stdout',
|
||||
StdErr: 'stderr',
|
||||
};
|
||||
|
||||
readonly _timeoutSettings = new TimeoutSettings();
|
||||
@ -280,6 +283,7 @@ export abstract class BrowserContext extends EventEmitter {
|
||||
|
||||
async close() {
|
||||
if (this._closedStatus === 'open') {
|
||||
this.emit(BrowserContext.Events.BeforeClose);
|
||||
this._closedStatus = 'closing';
|
||||
|
||||
for (const listener of contextListeners)
|
||||
|
@ -32,10 +32,7 @@ type ContextData = {
|
||||
contextPromise: Promise<dom.FrameExecutionContext>;
|
||||
contextResolveCallback: (c: dom.FrameExecutionContext) => void;
|
||||
context: dom.FrameExecutionContext | null;
|
||||
rerunnableTasks: Set<{
|
||||
rerun(context: dom.FrameExecutionContext): Promise<void>;
|
||||
terminate(error: Error): void;
|
||||
}>;
|
||||
rerunnableTasks: Set<RerunnableTask>;
|
||||
};
|
||||
|
||||
type DocumentInfo = {
|
||||
@ -1049,24 +1046,6 @@ export class Frame extends EventEmitter {
|
||||
this._parentFrame = null;
|
||||
}
|
||||
|
||||
async evaluateSurvivingNavigations<T>(callback: (context: dom.FrameExecutionContext) => Promise<T>, world: types.World) {
|
||||
return new Promise<T>((resolve, terminate) => {
|
||||
const data = this._contextData.get(world)!;
|
||||
const task = {
|
||||
terminate,
|
||||
async rerun(context: dom.FrameExecutionContext) {
|
||||
try {
|
||||
resolve(await callback(context));
|
||||
data.rerunnableTasks.delete(task);
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
data.rerunnableTasks.add(task);
|
||||
if (data.context)
|
||||
task.rerun(data.context);
|
||||
});
|
||||
}
|
||||
|
||||
private _scheduleRerunnableTask<T>(progress: Progress, world: types.World, task: dom.SchedulableTask<T>): Promise<T> {
|
||||
const data = this._contextData.get(world)!;
|
||||
const rerunnableTask = new RerunnableTask(data, progress, task, true /* returnByValue */);
|
||||
|
@ -492,31 +492,6 @@ export class Page extends EventEmitter {
|
||||
const identifier = PageBinding.identifier(name, world);
|
||||
return this._pageBindings.get(identifier) || this._browserContext._pageBindings.get(identifier);
|
||||
}
|
||||
|
||||
async pause() {
|
||||
if (!this._browserContext._browser._options.headful)
|
||||
throw new Error('Cannot pause in headless mode.');
|
||||
await this.mainFrame().evaluateSurvivingNavigations(async context => {
|
||||
await context.evaluateInternal(async () => {
|
||||
const element = document.createElement('playwright-resume');
|
||||
element.style.position = 'absolute';
|
||||
element.style.top = '10px';
|
||||
element.style.left = '10px';
|
||||
element.style.zIndex = '2147483646';
|
||||
element.style.opacity = '0.9';
|
||||
element.setAttribute('role', 'button');
|
||||
element.tabIndex = 0;
|
||||
element.style.fontSize = '50px';
|
||||
element.textContent = '▶️';
|
||||
element.title = 'Resume script';
|
||||
document.body.appendChild(element);
|
||||
await new Promise(x => {
|
||||
element.onclick = x;
|
||||
});
|
||||
element.remove();
|
||||
});
|
||||
}, 'utility');
|
||||
}
|
||||
}
|
||||
|
||||
export class Worker extends EventEmitter {
|
||||
|
@ -24,6 +24,9 @@ declare global {
|
||||
playwrightRecorderPerformAction: (action: actions.Action) => Promise<void>;
|
||||
playwrightRecorderRecordAction: (action: actions.Action) => Promise<void>;
|
||||
playwrightRecorderCommitAction: () => Promise<void>;
|
||||
playwrightRecorderState: () => Promise<{ state: any, paused: boolean, tool: 'codegen' | 'pause' }>;
|
||||
playwrightRecorderSetState: (state: any) => Promise<void>;
|
||||
playwrightRecorderResume: () => Promise<boolean>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,11 +40,19 @@ export class Recorder {
|
||||
private _innerGlassPaneElement: HTMLElement;
|
||||
private _highlightElements: HTMLElement[] = [];
|
||||
private _tooltipElement: HTMLElement;
|
||||
private _listeners: RegisteredListener[] = [];
|
||||
private _listeners: (() => void)[] = [];
|
||||
private _hoveredModel: HighlightModel | null = null;
|
||||
private _hoveredElement: HTMLElement | null = null;
|
||||
private _activeModel: HighlightModel | null = null;
|
||||
private _expectProgrammaticKeyUp = false;
|
||||
private _pollRecorderModeTimer: NodeJS.Timeout | undefined;
|
||||
private _toolbarElement: HTMLElement;
|
||||
private _inspectElement: HTMLElement;
|
||||
private _recordElement: HTMLElement;
|
||||
private _resumeElement: HTMLElement;
|
||||
private _mode: 'inspecting' | 'recording' | 'none' = 'none';
|
||||
private _tool: 'codegen' | 'pause' = 'pause';
|
||||
private _paused = false;
|
||||
|
||||
constructor(injectedScript: InjectedScript) {
|
||||
this._injectedScript = injectedScript;
|
||||
@ -53,7 +64,7 @@ export class Recorder {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2147483647;
|
||||
z-index: 2147483646;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
">
|
||||
@ -94,11 +105,108 @@ export class Recorder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
`);
|
||||
|
||||
this._toolbarElement = html`
|
||||
<x-pw-toolbar style="
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
left: 10px;
|
||||
z-index: 2147483647;
|
||||
background-color: #ffffffe6;
|
||||
padding: 4px;
|
||||
border-radius: 22px;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 0.25em 0.5em;
|
||||
flex-direction: column;"
|
||||
></x-pw-toolbar>`;
|
||||
|
||||
this._inspectElement = html`
|
||||
<x-pw-button tabIndex=0>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3c-.46-4.17-3.77-7.48-7.94-7.94V1h-2v2.06C6.83 3.52 3.52 6.83 3.06 11H1v2h2.06c.46 4.17 3.77 7.48 7.94 7.94V23h2v-2.06c4.17-.46 7.48-3.77 7.94-7.94H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>
|
||||
</x-pw-button>`;
|
||||
this._recordElement = html`
|
||||
<x-pw-button class="record" tabIndex=0>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="M24 24H0V0h24v24z" fill="none"/><circle cx="12" cy="12" r="8"/></svg>
|
||||
</x-pw-button>`;
|
||||
this._resumeElement = html`
|
||||
<x-pw-button tabIndex=0 class="playwright-resume">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></svg>
|
||||
</x-pw-button>`;
|
||||
|
||||
this._populateToolbar();
|
||||
this._pollRecorderMode();
|
||||
|
||||
setInterval(() => {
|
||||
this._refreshListenersIfNeeded();
|
||||
}, 100);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private _populateToolbar() {
|
||||
const toolbarShadow = this._toolbarElement.attachShadow({ mode: 'open' });
|
||||
toolbarShadow.appendChild(html`
|
||||
<style>
|
||||
x-pw-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 2px;
|
||||
fill: #333;
|
||||
}
|
||||
x-pw-button.logo {
|
||||
cursor: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
x-pw-button.toggled {
|
||||
fill: #468fd2;
|
||||
}
|
||||
x-pw-button:hover:not(.logo):not(.disabled) {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
x-pw-button.record.toggled {
|
||||
fill: red;
|
||||
}
|
||||
x-pw-button.disabled {
|
||||
fill: #777777 !important;
|
||||
cursor: inherit;
|
||||
}
|
||||
x-pw-button.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>`);
|
||||
|
||||
const iconElement = html`<x-pw-button class="logo" tabIndex=0 style="background-size: 32px 32px;"></x-pw-button>`;
|
||||
iconElement.style.backgroundImage = `url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTM2IDIyMmMtMTIgMy0yMSAxMC0yNiAxNiA1LTUgMTItOSAyMi0xMiAxMC0yIDE4LTIgMjUtMXYtNmMtNiAwLTEzIDAtMjEgM3ptLTI3LTQ2bC00OCAxMiAzIDMgNDAtMTBzMCA3LTUgMTRjOS03IDEwLTE5IDEwLTE5em00MCAxMTJDODIgMzA2IDQ2IDIyOCAzNSAxODhhMjI3IDIyNyAwIDAxLTctNDVjLTQgMS02IDItNSA4IDAgOSAyIDIzIDcgNDIgMTEgNDAgNDcgMTE4IDExNCAxMDAgMTUtNCAyNi0xMSAzNC0yMC03IDctMTcgMTItMjkgMTV6bTEzLTE2MHY1aDI2bC0yLTVoLTI0eiIgZmlsbD0iIzJENDU1MiIvPjxwYXRoIGQ9Ik0xOTQgMTY4YzEyIDMgMTggMTEgMjEgMTlsMTQgM3MtMi0yNS0yNS0zMmMtMjItNi0zNiAxMi0zNyAxNCA2LTQgMTUtOCAyNy00em0xMDUgMTljLTIxLTYtMzUgMTItMzYgMTQgNi00IDE1LTggMjctNSAxMiA0IDE4IDEyIDIxIDE5bDE0IDRzLTItMjYtMjYtMzJ6bS0xMyA2OGwtMTEwLTMxczEgNiA2IDE0bDkzIDI2IDExLTl6bS03NiA2NmMtODctMjMtNzctMTM0LTYzLTE4NyA2LTIyIDEyLTM4IDE3LTQ5LTMgMC01IDEtOCA2LTUgMTEtMTIgMjgtMTggNTItMTQgNTMtMjUgMTY0IDYyIDE4OCA0MSAxMSA3My02IDk3LTMyYTkwIDkwIDAgMDEtODcgMjJ6IiBmaWxsPSIjMkQ0NTUyIi8+PHBhdGggZD0iTTE2MiAyNjJ2LTIybC02MyAxOHM1LTI3IDM3LTM2YzEwLTMgMTktMyAyNi0ydi05MmgzMWwtMTAtMjRjLTQtOS05LTMtMTkgNi04IDYtMjcgMTktNTUgMjctMjkgOC01MiA2LTYxIDQtMTQtMi0yMS01LTIwIDUgMCA5IDIgMjMgNyA0MiAxMSA0MCA0NyAxMTggMTE0IDEwMCAxOC00IDMwLTE0IDM5LTI2aC0yNnpNNjEgMTg4bDQ4LTEycy0xIDE4LTE5IDIzLTI5LTExLTI5LTExeiIgZmlsbD0iI0UyNTc0QyIvPjxwYXRoIGQ9Ik0zNDIgMTI5Yy0xMyAyLTQzIDUtNzktNS0zNy0xMC02Mi0yNy03MS0zNS0xNC0xMi0yMC0yMC0yNi04LTUgMTEtMTIgMjktMTkgNTMtMTQgNTMtMjQgMTY0IDYzIDE4N3MxMzQtNzggMTQ4LTEzMWM2LTI0IDktNDIgMTAtNTQgMS0xNC05LTEwLTI2LTd6bS0xNzYgNDRzMTQtMjIgMzgtMTVjMjMgNyAyNSAzMiAyNSAzMmwtNjMtMTd6bTU3IDk2Yy00MS0xMi00Ny00NS00Ny00NWwxMTAgMzFzLTIyIDI2LTYzIDE0em0zOS02OHMxNC0yMSAzNy0xNGMyNCA2IDI2IDMyIDI2IDMybC02My0xOHoiIGZpbGw9IiMyRUFEMzMiLz48cGF0aCBkPSJNMTQwIDI0NmwtNDEgMTJzNS0yNiAzNS0zNmwtMjMtODYtMiAxYy0yOSA4LTUyIDYtNjEgNC0xNC0yLTIxLTUtMjAgNSAwIDkgMiAyMyA3IDQyIDExIDQwIDQ3IDExOCAxMTQgMTAwaDJsLTExLTQyem0tNzktNThsNDgtMTJzLTEgMTgtMTkgMjMtMjktMTEtMjktMTF6IiBmaWxsPSIjRDY1MzQ4Ii8+PHBhdGggZD0iTTIyNSAyNjloLTJjLTQxLTEyLTQ3LTQ1LTQ3LTQ1bDU3IDE2IDMwLTExNmMtMzctMTAtNjItMjctNzEtMzUtMTQtMTItMjAtMjAtMjYtOC01IDExLTEyIDI5LTE5IDUzLTE0IDUzLTI0IDE2NCA2MyAxODdsMiAxIDEzLTUzem0tNTktOTZzMTQtMjIgMzgtMTVjMjMgNyAyNSAzMiAyNSAzMmwtNjMtMTd6IiBmaWxsPSIjMUQ4RDIyIi8+PHBhdGggZD0iTTE0MiAyNDVsLTExIDRjMyAxNCA3IDI4IDE0IDQwbDQtMSA5LTNjLTgtMTItMTMtMjUtMTYtNDB6bS00LTEwMmMtNiAyMS0xMSA1MS0xMCA4MWw4LTIgMi0xYTI3MyAyNzMgMCAwMTE0LTEwM2wtOCA1LTYgMjB6IiBmaWxsPSIjQzA0QjQxIi8+PC9zdmc+')`;
|
||||
toolbarShadow.appendChild(iconElement);
|
||||
toolbarShadow.appendChild(this._inspectElement);
|
||||
toolbarShadow.appendChild(this._recordElement);
|
||||
toolbarShadow.appendChild(this._resumeElement);
|
||||
|
||||
this._inspectElement.addEventListener('click', () => {
|
||||
if (this._inspectElement.classList.contains('disabled'))
|
||||
return;
|
||||
this._inspectElement.classList.toggle('toggled');
|
||||
this._setMode(this._inspectElement.classList.contains('toggled') ? 'inspecting' : 'none');
|
||||
});
|
||||
this._recordElement.addEventListener('click', () => {
|
||||
if (this._recordElement.classList.contains('disabled'))
|
||||
return;
|
||||
this._recordElement.classList.toggle('toggled');
|
||||
this._setMode(this._recordElement.classList.contains('toggled') ? 'recording' : 'none');
|
||||
});
|
||||
this._resumeElement.addEventListener('click', () => {
|
||||
if (!this._resumeElement.classList.contains('disabled')) {
|
||||
this._setMode('none');
|
||||
window.playwrightRecorderResume().catch(e => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _refreshListenersIfNeeded() {
|
||||
@ -122,10 +230,47 @@ export class Recorder {
|
||||
}, true),
|
||||
];
|
||||
document.documentElement.appendChild(this._outerGlassPaneElement);
|
||||
document.documentElement.appendChild(this._toolbarElement);
|
||||
if ((window as any)._recorderScriptReadyForTest)
|
||||
(window as any)._recorderScriptReadyForTest();
|
||||
}
|
||||
|
||||
private async _setMode(mode: 'inspecting' | 'recording' | 'paused' | 'none') {
|
||||
window.playwrightRecorderSetState({ mode }).then(() => this._pollRecorderMode());
|
||||
}
|
||||
|
||||
private async _pollRecorderMode() {
|
||||
if (this._pollRecorderModeTimer)
|
||||
clearTimeout(this._pollRecorderModeTimer);
|
||||
const result = await window.playwrightRecorderState().catch(e => null);
|
||||
if (result) {
|
||||
const { state, paused, tool } = result;
|
||||
if (state && state.mode !== this._mode) {
|
||||
this._mode = state.mode as any;
|
||||
this._inspectElement.classList.toggle('toggled', this._mode === 'inspecting');
|
||||
this._recordElement.classList.toggle('toggled', this._mode === 'recording');
|
||||
this._inspectElement.classList.toggle('disabled', this._mode === 'recording');
|
||||
this._resumeElement.classList.toggle('disabled', this._mode === 'recording');
|
||||
this._clearHighlight();
|
||||
}
|
||||
if (paused !== this._paused) {
|
||||
this._paused = paused;
|
||||
this._resumeElement.classList.toggle('disabled', !this._paused);
|
||||
}
|
||||
if (tool !== this._tool) {
|
||||
this._tool = tool;
|
||||
this._resumeElement.classList.toggle('hidden', this._tool !== 'pause');
|
||||
}
|
||||
}
|
||||
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), 250);
|
||||
}
|
||||
|
||||
private _clearHighlight() {
|
||||
this._hoveredModel = null;
|
||||
this._activeModel = null;
|
||||
this._updateHighlight();
|
||||
}
|
||||
|
||||
private _actionInProgress(event: Event): boolean {
|
||||
// If Playwright is performing action for us, bail.
|
||||
if (this._performingAction)
|
||||
@ -143,7 +288,7 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _consumedDueWrongTarget(event: Event): boolean {
|
||||
if (this._activeModel && this._activeModel.elements[0] === deepEventTarget(event))
|
||||
if (this._activeModel && this._activeModel.elements[0] === this._deepEventTarget(event))
|
||||
return false;
|
||||
consumeEvent(event);
|
||||
return true;
|
||||
@ -157,7 +302,7 @@ export class Recorder {
|
||||
if (this._consumedDueToNoModel(event, this._hoveredModel))
|
||||
return;
|
||||
|
||||
const checkbox = asCheckbox(deepEventTarget(event));
|
||||
const checkbox = asCheckbox(this._deepEventTarget(event));
|
||||
if (checkbox) {
|
||||
// Interestingly, inputElement.checked is reversed inside this event handler.
|
||||
this._performAction({
|
||||
@ -178,8 +323,20 @@ export class Recorder {
|
||||
});
|
||||
}
|
||||
|
||||
private _isInToolbar(element: Element | undefined | null): boolean {
|
||||
return !!element && element.nodeName.toLowerCase().startsWith('x-pw-');
|
||||
}
|
||||
|
||||
private _shouldIgnoreMouseEvent(event: MouseEvent): boolean {
|
||||
const target = deepEventTarget(event);
|
||||
const target = this._deepEventTarget(event);
|
||||
if (this._isInToolbar(target))
|
||||
return true;
|
||||
if (this._mode === 'none')
|
||||
return true;
|
||||
if (this._mode === 'inspecting') {
|
||||
consumeEvent(event);
|
||||
return true;
|
||||
}
|
||||
const nodeName = target.nodeName;
|
||||
if (nodeName === 'SELECT')
|
||||
return true;
|
||||
@ -204,7 +361,11 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _onMouseMove(event: MouseEvent) {
|
||||
const target = deepEventTarget(event);
|
||||
if (this._mode === 'none')
|
||||
return;
|
||||
const target = this._deepEventTarget(event);
|
||||
if (this._isInToolbar(target))
|
||||
return;
|
||||
if (this._hoveredElement === target)
|
||||
return;
|
||||
this._hoveredElement = target;
|
||||
@ -214,14 +375,14 @@ export class Recorder {
|
||||
|
||||
private _onMouseLeave(event: MouseEvent) {
|
||||
// Leaving iframe.
|
||||
if (deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
|
||||
if (this._deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
|
||||
this._hoveredElement = null;
|
||||
this._commitActionAndUpdateModelForHoveredElement();
|
||||
}
|
||||
}
|
||||
|
||||
private _onFocus() {
|
||||
const activeElement = deepActiveElement(document);
|
||||
const activeElement = this._deepActiveElement(document);
|
||||
const result = activeElement ? generateSelector(this._injectedScript, activeElement) : null;
|
||||
this._activeModel = result && result.selector ? result : null;
|
||||
if ((window as any)._highlightUpdatedForTest)
|
||||
@ -290,7 +451,7 @@ export class Recorder {
|
||||
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.backgroundColor = this._highlightElements.length ? '#f6b26b7f' : '#6fa8dc7f';
|
||||
highlightElement.style.left = box.x + 'px';
|
||||
highlightElement.style.top = box.y + 'px';
|
||||
highlightElement.style.width = box.width + 'px';
|
||||
@ -313,7 +474,6 @@ export class Recorder {
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 1px solid;
|
||||
box-sizing: border-box;">
|
||||
</x-pw-highlight>`;
|
||||
this._glassPaneShadow.appendChild(highlightElement);
|
||||
@ -321,7 +481,9 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _onInput(event: Event) {
|
||||
const target = deepEventTarget(event);
|
||||
if (this._mode !== 'recording')
|
||||
return true;
|
||||
const target = this._deepEventTarget(event);
|
||||
if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
|
||||
const inputElement = target as HTMLInputElement;
|
||||
const elementType = (inputElement.type || '').toLowerCase();
|
||||
@ -385,11 +547,17 @@ export class Recorder {
|
||||
return false;
|
||||
const hasModifier = event.ctrlKey || event.altKey || event.metaKey;
|
||||
if (event.key.length === 1 && !hasModifier)
|
||||
return !!asCheckbox(deepEventTarget(event));
|
||||
return !!asCheckbox(this._deepEventTarget(event));
|
||||
return true;
|
||||
}
|
||||
|
||||
private _onKeyDown(event: KeyboardEvent) {
|
||||
if (this._mode === 'inspecting') {
|
||||
consumeEvent(event);
|
||||
return;
|
||||
}
|
||||
if (this._mode !== 'recording')
|
||||
return true;
|
||||
if (!this._shouldGenerateKeyPressFor(event))
|
||||
return;
|
||||
if (this._actionInProgress(event)) {
|
||||
@ -400,7 +568,7 @@ export class Recorder {
|
||||
return;
|
||||
// Similarly to click, trigger checkbox on key event, not input.
|
||||
if (event.key === ' ') {
|
||||
const checkbox = asCheckbox(deepEventTarget(event));
|
||||
const checkbox = asCheckbox(this._deepEventTarget(event));
|
||||
if (checkbox) {
|
||||
this._performAction({
|
||||
name: checkbox.checked ? 'uncheck' : 'check',
|
||||
@ -449,17 +617,17 @@ export class Recorder {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepEventTarget(event: Event): HTMLElement {
|
||||
return event.composedPath()[0] as HTMLElement;
|
||||
}
|
||||
private _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;
|
||||
private _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 {
|
||||
@ -500,14 +668,17 @@ type RegisteredListener = {
|
||||
useCapture?: boolean;
|
||||
};
|
||||
|
||||
function addEventListener(target: EventTarget, eventName: string, listener: EventListener, useCapture?: boolean): RegisteredListener {
|
||||
function addEventListener(target: EventTarget, eventName: string, listener: EventListener, useCapture?: boolean): () => void {
|
||||
target.addEventListener(eventName, listener, useCapture);
|
||||
return { target, eventName, listener, useCapture };
|
||||
const remove = () => {
|
||||
target.removeEventListener(eventName, listener, useCapture);
|
||||
};
|
||||
return remove;
|
||||
}
|
||||
|
||||
function removeEventListeners(listeners: RegisteredListener[]) {
|
||||
function removeEventListeners(listeners: (() => void)[]) {
|
||||
for (const listener of listeners)
|
||||
listener.target.removeEventListener(listener.eventName, listener.listener, listener.useCapture);
|
||||
listener();
|
||||
listeners.splice(0, listeners.length);
|
||||
}
|
||||
|
||||
|
@ -37,17 +37,19 @@ export class CodeGenerator {
|
||||
private _lastActionText: string | undefined;
|
||||
private _languageGenerator: LanguageGenerator;
|
||||
private _output: CodeGeneratorOutput;
|
||||
private _footerText: string;
|
||||
private _footerText = '';
|
||||
|
||||
constructor(browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, deviceName: string | undefined, saveStorage: string | undefined) {
|
||||
constructor(browserName: string, generateHeaders: boolean, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, deviceName: string | undefined, saveStorage: string | undefined) {
|
||||
this._output = output;
|
||||
this._languageGenerator = languageGenerator;
|
||||
|
||||
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);
|
||||
if (generateHeaders) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
addAction(action: ActionInContext) {
|
||||
@ -94,7 +96,8 @@ export class CodeGenerator {
|
||||
}
|
||||
|
||||
_printAction(actionInContext: ActionInContext, eraseLastAction: boolean) {
|
||||
this._output.popLn(this._footerText);
|
||||
if (this._footerText)
|
||||
this._output.popLn(this._footerText);
|
||||
if (eraseLastAction && this._lastActionText)
|
||||
this._output.popLn(this._lastActionText);
|
||||
const performingAction = !!this._currentAction;
|
||||
@ -102,7 +105,8 @@ export class CodeGenerator {
|
||||
this._lastAction = actionInContext;
|
||||
this._lastActionText = this._languageGenerator.generateAction(actionInContext, performingAction);
|
||||
this._output.printLn(this._lastActionText);
|
||||
this._output.printLn(this._footerText);
|
||||
if (this._footerText)
|
||||
this._output.printLn(this._footerText);
|
||||
}
|
||||
|
||||
signal(pageAlias: string, frame: Frame, signal: Signal) {
|
||||
|
@ -16,27 +16,46 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as querystring from 'querystring';
|
||||
import { Writable } from 'stream';
|
||||
import * as hljs from '../../third_party/highlightjs/highlightjs';
|
||||
import { RecorderOutput } from './recorderSupplement';
|
||||
import * as hljs from '../../../third_party/highlightjs/highlightjs';
|
||||
|
||||
export interface RecorderOutput {
|
||||
printLn(text: string): void;
|
||||
popLn(text: string): void;
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
export interface Writable {
|
||||
write(data: string): void;
|
||||
}
|
||||
|
||||
export class OutputMultiplexer implements RecorderOutput {
|
||||
private _outputs: RecorderOutput[]
|
||||
private _enabled = true;
|
||||
constructor(outputs: RecorderOutput[]) {
|
||||
this._outputs = outputs;
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean) {
|
||||
this._enabled = enabled;
|
||||
}
|
||||
|
||||
printLn(text: string) {
|
||||
if (!this._enabled)
|
||||
return;
|
||||
for (const output of this._outputs)
|
||||
output.printLn(text);
|
||||
}
|
||||
|
||||
popLn(text: string) {
|
||||
if (!this._enabled)
|
||||
return;
|
||||
for (const output of this._outputs)
|
||||
output.popLn(text);
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (!this._enabled)
|
||||
return;
|
||||
for (const output of this._outputs)
|
||||
output.flush();
|
||||
}
|
||||
@ -64,6 +83,7 @@ export class FileOutput extends BufferOutput implements RecorderOutput {
|
||||
constructor(fileName: string) {
|
||||
super();
|
||||
this._fileName = fileName;
|
||||
process.on('exit', () => this.flush());
|
||||
}
|
||||
|
||||
flush() {
|
||||
@ -72,7 +92,7 @@ export class FileOutput extends BufferOutput implements RecorderOutput {
|
||||
}
|
||||
|
||||
export class TerminalOutput implements RecorderOutput {
|
||||
private _output: Writable
|
||||
private _output: Writable;
|
||||
private _language: string;
|
||||
|
||||
static create(output: Writable, language: string) {
|
@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
import * as actions from './recorder/recorderActions';
|
||||
import { CodeGenerator, ActionInContext, CodeGeneratorOutput } from './recorder/codeGenerator';
|
||||
import type * as channels from '../../protocol/channels';
|
||||
import { CodeGenerator, ActionInContext } from './recorder/codeGenerator';
|
||||
import { toClickOptions, toModifiers } from './recorder/utils';
|
||||
import { Page } from '../page';
|
||||
import { Frame } from '../frames';
|
||||
@ -26,8 +27,15 @@ import { CSharpLanguageGenerator } from './recorder/csharp';
|
||||
import { PythonLanguageGenerator } from './recorder/python';
|
||||
import { ProgressController } from '../progress';
|
||||
import * as recorderSource from '../../generated/recorderSource';
|
||||
import * as consoleApiSource from '../../generated/consoleApiSource';
|
||||
import { FileOutput, FlushingTerminalOutput, OutputMultiplexer, RecorderOutput, TerminalOutput, Writable } from './recorder/outputs';
|
||||
|
||||
type BindingSource = { frame: Frame, page: Page };
|
||||
type Tool = 'codegen' | 'pause';
|
||||
type Mode = 'inspecting' | 'recording' | 'none';
|
||||
|
||||
const symbol = Symbol('RecorderSupplement');
|
||||
|
||||
|
||||
export class RecorderSupplement {
|
||||
private _generator: CodeGenerator;
|
||||
@ -36,11 +44,27 @@ export class RecorderSupplement {
|
||||
private _lastDialogOrdinal = 0;
|
||||
private _timers = new Set<NodeJS.Timeout>();
|
||||
private _context: BrowserContext;
|
||||
private _resumeCallback: (() => void) | null = null;
|
||||
private _recorderState: { mode: Mode };
|
||||
private _paused = false;
|
||||
private _tool: Tool;
|
||||
private _output: OutputMultiplexer;
|
||||
|
||||
constructor(context: BrowserContext, params: { language: string, launchOptions: any, contextOptions: any, device?: string, saveStorage?: string}, output: CodeGeneratorOutput) {
|
||||
static getOrCreate(context: BrowserContext, tool: Tool, params: channels.BrowserContextRecorderSupplementEnableParams): Promise<RecorderSupplement> {
|
||||
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
|
||||
if (!recorderPromise) {
|
||||
const recorder = new RecorderSupplement(context, tool, params);
|
||||
recorderPromise = recorder.install().then(() => recorder);
|
||||
(context as any)[symbol] = recorderPromise;
|
||||
}
|
||||
return recorderPromise;
|
||||
}
|
||||
|
||||
constructor(context: BrowserContext, tool: Tool, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
||||
this._context = context;
|
||||
this._tool = tool;
|
||||
this._recorderState = { mode: tool === 'codegen' ? 'recording' : 'none' };
|
||||
let languageGenerator: LanguageGenerator;
|
||||
|
||||
switch (params.language) {
|
||||
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break;
|
||||
case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break;
|
||||
@ -48,7 +72,21 @@ export class RecorderSupplement {
|
||||
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);
|
||||
let highlighterType = params.language;
|
||||
if (highlighterType === 'python-async')
|
||||
highlighterType = 'python';
|
||||
|
||||
const writable: Writable = {
|
||||
write: (text: string) => context.emit(BrowserContext.Events.StdOut, text)
|
||||
};
|
||||
const outputs: RecorderOutput[] = [params.terminal ? new TerminalOutput(writable, highlighterType) : new FlushingTerminalOutput(writable)];
|
||||
if (params.outputFile)
|
||||
outputs.push(new FileOutput(params.outputFile));
|
||||
this._output = new OutputMultiplexer(outputs);
|
||||
this._output.setEnabled(tool === 'codegen');
|
||||
context.on(BrowserContext.Events.BeforeClose, () => this._output.flush());
|
||||
|
||||
const generator = new CodeGenerator(context._browser._options.name, tool === 'codegen', params.launchOptions || {}, params.contextOptions || {}, this._output, languageGenerator, params.device, params.saveStorage);
|
||||
this._generator = generator;
|
||||
}
|
||||
|
||||
@ -76,7 +114,34 @@ export class RecorderSupplement {
|
||||
await this._context.exposeBinding('playwrightRecorderCommitAction', false,
|
||||
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction());
|
||||
|
||||
await this._context.exposeBinding('playwrightRecorderState', false, () => {
|
||||
return {
|
||||
state: this._recorderState,
|
||||
tool: this._tool,
|
||||
paused: this._paused
|
||||
};
|
||||
});
|
||||
|
||||
await this._context.exposeBinding('playwrightRecorderSetState', false, (source, state) => {
|
||||
this._recorderState = state;
|
||||
this._output.setEnabled(state.mode === 'recording');
|
||||
});
|
||||
|
||||
await this._context.exposeBinding('playwrightRecorderResume', false, () => {
|
||||
if (this._resumeCallback) {
|
||||
this._resumeCallback();
|
||||
this._resumeCallback = null;
|
||||
}
|
||||
this._paused = false;
|
||||
});
|
||||
|
||||
await this._context.extendInjectedScript(recorderSource.source);
|
||||
await this._context.extendInjectedScript(consoleApiSource.source);
|
||||
}
|
||||
|
||||
async pause() {
|
||||
this._paused = true;
|
||||
return new Promise(f => this._resumeCallback = f);
|
||||
}
|
||||
|
||||
private async _onPage(page: Page) {
|
||||
|
@ -15,14 +15,11 @@
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import { Writable } from 'stream';
|
||||
import * as path from 'path';
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import { folio as baseFolio } from '../fixtures';
|
||||
import type { Page, BrowserType, Browser, BrowserContext } from '../..';
|
||||
export { config } from 'folio';
|
||||
import { FlushingTerminalOutput } from '../../lib/client/supplements/recorderOutputs';
|
||||
import { RecorderSupplement } from '../../lib/client/supplements/recorderSupplement';
|
||||
|
||||
type WorkerFixtures = {
|
||||
browserType: BrowserType<Browser>;
|
||||
@ -41,8 +38,8 @@ export const fixtures = baseFolio.extend<TestFixtures, WorkerFixtures>();
|
||||
fixtures.contextWrapper.init(async ({ browser }, runTest) => {
|
||||
const context = await browser.newContext() as BrowserContext;
|
||||
const outputBuffer = new WritableBuffer();
|
||||
const output = new FlushingTerminalOutput(outputBuffer as any as Writable);
|
||||
new RecorderSupplement(context, 'javascript', {}, {}, undefined, undefined, output);
|
||||
(context as any)._stdout = outputBuffer;
|
||||
await (context as any)._enableRecorder('javascript');
|
||||
await runTest({ context, output: outputBuffer });
|
||||
await context.close();
|
||||
});
|
||||
@ -88,14 +85,10 @@ class WritableBuffer {
|
||||
this._data = '';
|
||||
}
|
||||
|
||||
write(chunk: string) {
|
||||
if (!chunk)
|
||||
write(data: Buffer) {
|
||||
if (!data)
|
||||
return;
|
||||
if (chunk === '\u001B[F\u001B[2K') {
|
||||
const index = this._data.lastIndexOf('\n');
|
||||
this._data = this._data.substring(0, index);
|
||||
return;
|
||||
}
|
||||
const chunk = data.toString('utf8');
|
||||
this._data += chunk;
|
||||
if (this._callback && chunk.includes(this._text))
|
||||
this._callback();
|
||||
|
@ -27,7 +27,7 @@ it('should pause and resume the script', async ({page}) => {
|
||||
const resumePromise = (page as any)._pause().then(() => resolved = true);
|
||||
await new Promise(x => setTimeout(x, 0));
|
||||
expect(resolved).toBe(false);
|
||||
await page.click('playwright-resume');
|
||||
await page.click('.playwright-resume');
|
||||
await resumePromise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
@ -38,7 +38,7 @@ it('should pause through a navigation', async ({page, server}) => {
|
||||
await new Promise(x => setTimeout(x, 0));
|
||||
expect(resolved).toBe(false);
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await page.click('playwright-resume');
|
||||
await page.click('.playwright-resume');
|
||||
await resumePromise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
@ -50,7 +50,7 @@ it('should pause after a navigation', async ({page, server}) => {
|
||||
const resumePromise = (page as any)._pause().then(() => resolved = true);
|
||||
await new Promise(x => setTimeout(x, 0));
|
||||
expect(resolved).toBe(false);
|
||||
await page.click('playwright-resume');
|
||||
await page.click('.playwright-resume');
|
||||
await resumePromise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
@ -16,11 +16,10 @@
|
||||
|
||||
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) => {
|
||||
new ConsoleApiSupplement(context);
|
||||
await (context as any)._enableConsoleApi();
|
||||
await run(context);
|
||||
});
|
||||
const { describe, it, expect } = fixtures.build();
|
||||
|
@ -126,6 +126,7 @@ DEPS['src/server/'] = [
|
||||
'src/server/common/**',
|
||||
'src/server/injected/**',
|
||||
'src/server/supplements/**',
|
||||
'src/protocol/**',
|
||||
];
|
||||
|
||||
// No dependencies for code shared between node and page.
|
||||
@ -133,8 +134,6 @@ DEPS['src/server/common/'] = [];
|
||||
// Strict dependencies for injected code.
|
||||
DEPS['src/server/injected/'] = ['src/server/common/'];
|
||||
|
||||
DEPS['src/client/supplements/'] = ['src/client/'];
|
||||
|
||||
// Electron and Clank use chromium internally.
|
||||
DEPS['src/server/android/'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/protocol/'];
|
||||
DEPS['src/server/electron/'] = [...DEPS['src/server/'], 'src/server/chromium/'];
|
||||
|
Loading…
x
Reference in New Issue
Block a user