chore: iterate towards recording into trace (#32646)
This commit is contained in:
parent
623b2e6bc8
commit
355c88f48f
|
|
@ -567,10 +567,6 @@ async function codegen(options: Options & { target: string, output?: string, tes
|
||||||
tracesDir,
|
tracesDir,
|
||||||
});
|
});
|
||||||
dotenv.config({ path: 'playwright.env' });
|
dotenv.config({ path: 'playwright.env' });
|
||||||
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
|
|
||||||
await fs.promises.mkdir(tracesDir, { recursive: true });
|
|
||||||
await context.tracing.start({ name: 'trace', _live: true });
|
|
||||||
}
|
|
||||||
await context._enableRecorder({
|
await context._enableRecorder({
|
||||||
language,
|
language,
|
||||||
launchOptions,
|
launchOptions,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import type * as types from '../types';
|
||||||
import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
|
|
||||||
export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) {
|
export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) {
|
||||||
actions = collapseActions(actions);
|
|
||||||
const header = languageGenerator.generateHeader(options);
|
const header = languageGenerator.generateHeader(options);
|
||||||
const footer = languageGenerator.generateFooter(options.saveStorage);
|
const footer = languageGenerator.generateFooter(options.saveStorage);
|
||||||
const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean);
|
const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean);
|
||||||
|
|
@ -70,6 +69,23 @@ export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModif
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fromKeyboardModifiers(modifiers?: types.SmartKeyboardModifier[]): number {
|
||||||
|
let result = 0;
|
||||||
|
if (!modifiers)
|
||||||
|
return result;
|
||||||
|
if (modifiers.includes('Alt'))
|
||||||
|
result |= 1;
|
||||||
|
if (modifiers.includes('Control'))
|
||||||
|
result |= 2;
|
||||||
|
if (modifiers.includes('ControlOrMeta'))
|
||||||
|
result |= 2;
|
||||||
|
if (modifiers.includes('Meta'))
|
||||||
|
result |= 4;
|
||||||
|
if (modifiers.includes('Shift'))
|
||||||
|
result |= 8;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions {
|
export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions {
|
||||||
const modifiers = toKeyboardModifiers(action.modifiers);
|
const modifiers = toKeyboardModifiers(action.modifiers);
|
||||||
const options: types.MouseClickOptions = {};
|
const options: types.MouseClickOptions = {};
|
||||||
|
|
@ -84,19 +100,3 @@ export function toClickOptionsForSourceCode(action: actions.ClickAction): types.
|
||||||
options.position = action.position;
|
options.position = action.position;
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseActions(actions: ActionInContext[]): ActionInContext[] {
|
|
||||||
const result: ActionInContext[] = [];
|
|
||||||
for (const action of actions) {
|
|
||||||
const lastAction = result[result.length - 1];
|
|
||||||
const isSameAction = lastAction && lastAction.action.name === action.action.name && lastAction.frame.pageAlias === action.frame.pageAlias && lastAction.frame.framePath.join('|') === action.frame.framePath.join('|');
|
|
||||||
const isSameSelector = lastAction && 'selector' in lastAction.action && 'selector' in action.action && action.action.selector === lastAction.action.selector;
|
|
||||||
const shouldMerge = isSameAction && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector));
|
|
||||||
if (!shouldMerge) {
|
|
||||||
result.push(action);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result[result.length - 1] = action;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import { serializeError } from '../errors';
|
||||||
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
|
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
|
||||||
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
|
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
|
||||||
import { RecorderApp } from '../recorder/recorderApp';
|
import { RecorderApp } from '../recorder/recorderApp';
|
||||||
|
import type { IRecorderAppFactory } from '../recorder/recorderFrontend';
|
||||||
|
|
||||||
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
|
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
|
||||||
_type_EventTarget = true;
|
_type_EventTarget = true;
|
||||||
|
|
@ -293,7 +294,20 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
|
||||||
}
|
}
|
||||||
|
|
||||||
async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> {
|
async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> {
|
||||||
const factory = process.env.PW_RECORDER_IS_TRACE_VIEWER ? RecorderInTraceViewer.factory(this._context) : RecorderApp.factory(this._context);
|
let factory: IRecorderAppFactory;
|
||||||
|
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
|
||||||
|
factory = RecorderInTraceViewer.factory(this._context);
|
||||||
|
await this._context.tracing.start({
|
||||||
|
name: 'trace',
|
||||||
|
snapshots: true,
|
||||||
|
screenshots: false,
|
||||||
|
live: true,
|
||||||
|
inMemory: true,
|
||||||
|
});
|
||||||
|
await this._context.tracing.startChunk({ name: 'trace', title: 'trace' });
|
||||||
|
} else {
|
||||||
|
factory = RecorderApp.factory(this._context);
|
||||||
|
}
|
||||||
await Recorder.show(this._context, factory, params);
|
await Recorder.show(this._context, factory, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export class ContextRecorder extends EventEmitter {
|
||||||
saveStorage: params.saveStorage,
|
saveStorage: params.saveStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
const collection = new RecorderCollection(this._pageAliases, params.mode === 'recording');
|
const collection = new RecorderCollection(context, this._pageAliases, params.mode === 'recording');
|
||||||
collection.on('change', () => {
|
collection.on('change', () => {
|
||||||
this._recorderSources = [];
|
this._recorderSources = [];
|
||||||
for (const languageGenerator of this._orderedLanguages) {
|
for (const languageGenerator of this._orderedLanguages) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
|
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
|
wsEndpointForTest: undefined;
|
||||||
async close(): Promise<void> {}
|
async close(): Promise<void> {}
|
||||||
async setPaused(paused: boolean): Promise<void> {}
|
async setPaused(paused: boolean): Promise<void> {}
|
||||||
async setMode(mode: Mode): Promise<void> {}
|
async setMode(mode: Mode): Promise<void> {}
|
||||||
|
|
@ -54,7 +55,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
|
|
||||||
export class RecorderApp extends EventEmitter implements IRecorderApp {
|
export class RecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
private _page: Page;
|
private _page: Page;
|
||||||
readonly wsEndpoint: string | undefined;
|
readonly wsEndpointForTest: string | undefined;
|
||||||
private _recorder: IRecorder;
|
private _recorder: IRecorder;
|
||||||
|
|
||||||
constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) {
|
constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) {
|
||||||
|
|
@ -62,7 +63,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
this.setMaxListeners(0);
|
this.setMaxListeners(0);
|
||||||
this._recorder = recorder;
|
this._recorder = recorder;
|
||||||
this._page = page;
|
this._page = page;
|
||||||
this.wsEndpoint = wsEndpoint;
|
this.wsEndpointForTest = wsEndpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
|
|
|
||||||
|
|
@ -20,31 +20,35 @@ import type { Page } from '../page';
|
||||||
import type { Signal } from './recorderActions';
|
import type { Signal } from './recorderActions';
|
||||||
import type { ActionInContext } from '../codegen/types';
|
import type { ActionInContext } from '../codegen/types';
|
||||||
import { monotonicTime } from '../../utils/time';
|
import { monotonicTime } from '../../utils/time';
|
||||||
import { callMetadataForAction } from './recorderUtils';
|
import { callMetadataForAction, collapseActions, traceEventsToAction } from './recorderUtils';
|
||||||
import { serializeError } from '../errors';
|
import { serializeError } from '../errors';
|
||||||
import { performAction } from './recorderRunner';
|
import { performAction } from './recorderRunner';
|
||||||
import type { CallMetadata } from '@protocol/callMetadata';
|
import type { CallMetadata } from '@protocol/callMetadata';
|
||||||
import { isUnderTest } from '../../utils/debug';
|
import { isUnderTest } from '../../utils/debug';
|
||||||
|
import type { BrowserContext } from '../browserContext';
|
||||||
|
|
||||||
export class RecorderCollection extends EventEmitter {
|
export class RecorderCollection extends EventEmitter {
|
||||||
private _actions: ActionInContext[] = [];
|
private _actions: ActionInContext[] = [];
|
||||||
private _enabled: boolean;
|
private _enabled: boolean;
|
||||||
private _pageAliases: Map<Page, string>;
|
private _pageAliases: Map<Page, string>;
|
||||||
|
private _context: BrowserContext;
|
||||||
|
|
||||||
constructor(pageAliases: Map<Page, string>, enabled: boolean) {
|
constructor(context: BrowserContext, pageAliases: Map<Page, string>, enabled: boolean) {
|
||||||
super();
|
super();
|
||||||
|
this._context = context;
|
||||||
this._enabled = enabled;
|
this._enabled = enabled;
|
||||||
this._pageAliases = pageAliases;
|
this._pageAliases = pageAliases;
|
||||||
this.restart();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
restart() {
|
restart() {
|
||||||
this._actions = [];
|
this._actions = [];
|
||||||
this.emit('change');
|
this._fireChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
actions() {
|
actions() {
|
||||||
return this._actions;
|
if (!process.env.PW_RECORDER_IS_TRACE_VIEWER)
|
||||||
|
return collapseActions(this._actions);
|
||||||
|
return collapseActions(traceEventsToAction(this._context.tracing.inMemoryEvents()));
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnabled(enabled: boolean) {
|
setEnabled(enabled: boolean) {
|
||||||
|
|
@ -60,7 +64,7 @@ export class RecorderCollection extends EventEmitter {
|
||||||
addRecordedAction(actionInContext: ActionInContext) {
|
addRecordedAction(actionInContext: ActionInContext) {
|
||||||
if (['openPage', 'closePage'].includes(actionInContext.action.name)) {
|
if (['openPage', 'closePage'].includes(actionInContext.action.name)) {
|
||||||
this._actions.push(actionInContext);
|
this._actions.push(actionInContext);
|
||||||
this.emit('change');
|
this._fireChange();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._addAction(actionInContext).catch(() => {});
|
this._addAction(actionInContext).catch(() => {});
|
||||||
|
|
@ -69,11 +73,16 @@ export class RecorderCollection extends EventEmitter {
|
||||||
private async _addAction(actionInContext: ActionInContext, callback?: (callMetadata: CallMetadata) => Promise<void>) {
|
private async _addAction(actionInContext: ActionInContext, callback?: (callMetadata: CallMetadata) => Promise<void>) {
|
||||||
if (!this._enabled)
|
if (!this._enabled)
|
||||||
return;
|
return;
|
||||||
|
if (actionInContext.action.name === 'openPage' || actionInContext.action.name === 'closePage') {
|
||||||
|
this._actions.push(actionInContext);
|
||||||
|
this._fireChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { callMetadata, mainFrame } = callMetadataForAction(this._pageAliases, actionInContext);
|
const { callMetadata, mainFrame } = callMetadataForAction(this._pageAliases, actionInContext);
|
||||||
await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
|
await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
|
||||||
this._actions.push(actionInContext);
|
this._actions.push(actionInContext);
|
||||||
this.emit('change');
|
this._fireChange();
|
||||||
const error = await callback?.(callMetadata).catch((e: Error) => e);
|
const error = await callback?.(callMetadata).catch((e: Error) => e);
|
||||||
callMetadata.endTime = monotonicTime();
|
callMetadata.endTime = monotonicTime();
|
||||||
callMetadata.error = error ? serializeError(error) : undefined;
|
callMetadata.error = error ? serializeError(error) : undefined;
|
||||||
|
|
@ -120,4 +129,8 @@ export class RecorderCollection extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _fireChange() {
|
||||||
|
this.emit('change');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface IRecorder {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRecorderApp extends EventEmitter {
|
export interface IRecorderApp extends EventEmitter {
|
||||||
|
readonly wsEndpointForTest: string | undefined;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
setPaused(paused: boolean): Promise<void>;
|
setPaused(paused: boolean): Promise<void>;
|
||||||
setMode(mode: Mode): Promise<void>;
|
setMode(mode: Mode): Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher';
|
||||||
import type { Transport } from '../../utils/httpServer';
|
import type { Transport } from '../../utils/httpServer';
|
||||||
|
|
||||||
export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp {
|
export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp {
|
||||||
|
readonly wsEndpointForTest: string | undefined;
|
||||||
private _recorder: IRecorder;
|
private _recorder: IRecorder;
|
||||||
private _transport: Transport;
|
private _transport: Transport;
|
||||||
|
|
||||||
|
|
@ -32,15 +33,16 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp
|
||||||
return async (recorder: IRecorder) => {
|
return async (recorder: IRecorder) => {
|
||||||
const transport = new RecorderTransport();
|
const transport = new RecorderTransport();
|
||||||
const trace = path.join(context._browser.options.tracesDir, 'trace');
|
const trace = path.join(context._browser.options.tracesDir, 'trace');
|
||||||
await openApp(trace, { transport });
|
const wsEndpointForTest = await openApp(trace, { transport, headless: !context._browser.options.headful });
|
||||||
return new RecorderInTraceViewer(context, recorder, transport);
|
return new RecorderInTraceViewer(context, recorder, transport, wsEndpointForTest);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(context: BrowserContext, recorder: IRecorder, transport: Transport) {
|
constructor(context: BrowserContext, recorder: IRecorder, transport: Transport, wsEndpointForTest: string | undefined) {
|
||||||
super();
|
super();
|
||||||
this._recorder = recorder;
|
this._recorder = recorder;
|
||||||
this._transport = transport;
|
this._transport = transport;
|
||||||
|
this.wsEndpointForTest = wsEndpointForTest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
|
|
@ -72,11 +74,12 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }) {
|
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }): Promise<string | undefined> {
|
||||||
const server = await startTraceViewerServer(options);
|
const server = await startTraceViewerServer(options);
|
||||||
await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' });
|
await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' });
|
||||||
const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options);
|
const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options);
|
||||||
page.on('close', () => gracefullyProcessExitDoNotHang(0));
|
page.on('close', () => gracefullyProcessExitDoNotHang(0));
|
||||||
|
return page.context()._browser.options.wsEndpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
class RecorderTransport implements Transport {
|
class RecorderTransport implements Transport {
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,12 @@ import type { Page } from '../page';
|
||||||
import type { ActionInContext } from '../codegen/types';
|
import type { ActionInContext } from '../codegen/types';
|
||||||
import type { Frame } from '../frames';
|
import type { Frame } from '../frames';
|
||||||
import type * as actions from './recorderActions';
|
import type * as actions from './recorderActions';
|
||||||
import { toKeyboardModifiers } from '../codegen/language';
|
import type * as channels from '@protocol/channels';
|
||||||
|
import type * as trace from '@trace/trace';
|
||||||
|
import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language';
|
||||||
import { serializeExpectedTextValues } from '../../utils/expectUtils';
|
import { serializeExpectedTextValues } from '../../utils/expectUtils';
|
||||||
import { createGuid, monotonicTime } from '../../utils';
|
import { createGuid, monotonicTime } from '../../utils';
|
||||||
|
import { serializeValue } from '../../protocol/serializers';
|
||||||
|
|
||||||
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
|
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
|
||||||
let title = metadata.apiName || metadata.method;
|
let title = metadata.apiName || metadata.method;
|
||||||
|
|
@ -76,57 +79,113 @@ export async function frameForAction(pageAliases: Map<Page, string>, actionInCon
|
||||||
return result.frame;
|
return result.frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function traceParamsForAction(actionInContext: ActionInContext) {
|
export function traceParamsForAction(actionInContext: ActionInContext): { method: string, params: any } {
|
||||||
const { action } = actionInContext;
|
const { action } = actionInContext;
|
||||||
|
|
||||||
switch (action.name) {
|
switch (action.name) {
|
||||||
case 'navigate': return { url: action.url };
|
case 'navigate': {
|
||||||
case 'openPage': return {};
|
const params: channels.FrameGotoParams = {
|
||||||
case 'closePage': return {};
|
url: action.url,
|
||||||
|
};
|
||||||
|
return { method: 'goto', params };
|
||||||
|
}
|
||||||
|
case 'openPage':
|
||||||
|
case 'closePage':
|
||||||
|
throw new Error('Not reached');
|
||||||
}
|
}
|
||||||
|
|
||||||
const selector = buildFullSelector(actionInContext.frame.framePath, action.selector);
|
const selector = buildFullSelector(actionInContext.frame.framePath, action.selector);
|
||||||
switch (action.name) {
|
switch (action.name) {
|
||||||
case 'click': return { selector, clickCount: action.clickCount };
|
case 'click': {
|
||||||
case 'press': {
|
const params: channels.FrameClickParams = {
|
||||||
const modifiers = toKeyboardModifiers(action.modifiers);
|
|
||||||
const shortcut = [...modifiers, action.key].join('+');
|
|
||||||
return { selector, key: shortcut };
|
|
||||||
}
|
|
||||||
case 'fill': return { selector, text: action.text };
|
|
||||||
case 'setInputFiles': return { selector, files: action.files };
|
|
||||||
case 'check': return { selector };
|
|
||||||
case 'uncheck': return { selector };
|
|
||||||
case 'select': return { selector, values: action.options.map(value => ({ value })) };
|
|
||||||
case 'assertChecked': {
|
|
||||||
return {
|
|
||||||
selector,
|
selector,
|
||||||
expression: 'to.be.checked',
|
strict: true,
|
||||||
isNot: !action.checked,
|
modifiers: toKeyboardModifiers(action.modifiers),
|
||||||
|
button: action.button,
|
||||||
|
clickCount: action.clickCount,
|
||||||
|
position: action.position,
|
||||||
};
|
};
|
||||||
|
return { method: 'click', params };
|
||||||
|
}
|
||||||
|
case 'press': {
|
||||||
|
const params: channels.FramePressParams = {
|
||||||
|
selector,
|
||||||
|
strict: true,
|
||||||
|
key: [...toKeyboardModifiers(action.modifiers), action.key].join('+'),
|
||||||
|
};
|
||||||
|
return { method: 'press', params };
|
||||||
|
}
|
||||||
|
case 'fill': {
|
||||||
|
const params: channels.FrameFillParams = {
|
||||||
|
selector,
|
||||||
|
strict: true,
|
||||||
|
value: action.text,
|
||||||
|
};
|
||||||
|
return { method: 'fill', params };
|
||||||
|
}
|
||||||
|
case 'setInputFiles': {
|
||||||
|
const params: channels.FrameSetInputFilesParams = {
|
||||||
|
selector,
|
||||||
|
strict: true,
|
||||||
|
localPaths: action.files,
|
||||||
|
};
|
||||||
|
return { method: 'setInputFiles', params };
|
||||||
|
}
|
||||||
|
case 'check': {
|
||||||
|
const params: channels.FrameCheckParams = {
|
||||||
|
selector,
|
||||||
|
strict: true,
|
||||||
|
};
|
||||||
|
return { method: 'check', params };
|
||||||
|
}
|
||||||
|
case 'uncheck': {
|
||||||
|
const params: channels.FrameUncheckParams = {
|
||||||
|
selector,
|
||||||
|
strict: true,
|
||||||
|
};
|
||||||
|
return { method: 'uncheck', params };
|
||||||
|
}
|
||||||
|
case 'select': {
|
||||||
|
const params: channels.FrameSelectOptionParams = {
|
||||||
|
selector,
|
||||||
|
strict: true,
|
||||||
|
options: action.options.map(option => ({ value: option })),
|
||||||
|
};
|
||||||
|
return { method: 'selectOption', params };
|
||||||
|
}
|
||||||
|
case 'assertChecked': {
|
||||||
|
const params: channels.FrameExpectParams = {
|
||||||
|
selector: action.selector,
|
||||||
|
expression: 'to.be.checked',
|
||||||
|
isNot: action.checked,
|
||||||
|
};
|
||||||
|
return { method: 'expect', params };
|
||||||
}
|
}
|
||||||
case 'assertText': {
|
case 'assertText': {
|
||||||
return {
|
const params: channels.FrameExpectParams = {
|
||||||
selector,
|
selector,
|
||||||
expression: 'to.have.text',
|
expression: 'to.have.text',
|
||||||
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
|
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
|
||||||
isNot: false,
|
isNot: false,
|
||||||
};
|
};
|
||||||
|
return { method: 'expect', params };
|
||||||
}
|
}
|
||||||
case 'assertValue': {
|
case 'assertValue': {
|
||||||
return {
|
const params: channels.FrameExpectParams = {
|
||||||
selector,
|
selector,
|
||||||
expression: 'to.have.value',
|
expression: 'to.have.value',
|
||||||
expectedValue: action.value,
|
expectedValue: { value: serializeValue(action.value, value => ({ fallThrough: value })), handles: [] },
|
||||||
isNot: false,
|
isNot: false,
|
||||||
};
|
};
|
||||||
|
return { method: 'expect', params };
|
||||||
}
|
}
|
||||||
case 'assertVisible': {
|
case 'assertVisible': {
|
||||||
return {
|
const params: channels.FrameExpectParams = {
|
||||||
selector,
|
selector,
|
||||||
expression: 'to.be.visible',
|
expression: 'to.be.visible',
|
||||||
isNot: false,
|
isNot: false,
|
||||||
};
|
};
|
||||||
|
return { method: 'expect', params };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,8 +193,10 @@ export function traceParamsForAction(actionInContext: ActionInContext) {
|
||||||
export function callMetadataForAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
|
export function callMetadataForAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
|
||||||
const mainFrame = mainFrameForAction(pageAliases, actionInContext);
|
const mainFrame = mainFrameForAction(pageAliases, actionInContext);
|
||||||
const { action } = actionInContext;
|
const { action } = actionInContext;
|
||||||
|
const { method, params } = traceParamsForAction(actionInContext);
|
||||||
const callMetadata: CallMetadata = {
|
const callMetadata: CallMetadata = {
|
||||||
id: `call@${createGuid()}`,
|
id: `call@${createGuid()}`,
|
||||||
|
stepId: `recorder@${createGuid()}`,
|
||||||
apiName: 'frame.' + action.name,
|
apiName: 'frame.' + action.name,
|
||||||
objectId: mainFrame.guid,
|
objectId: mainFrame.guid,
|
||||||
pageId: mainFrame._page.guid,
|
pageId: mainFrame._page.guid,
|
||||||
|
|
@ -143,9 +204,132 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
|
||||||
startTime: monotonicTime(),
|
startTime: monotonicTime(),
|
||||||
endTime: 0,
|
endTime: 0,
|
||||||
type: 'Frame',
|
type: 'Frame',
|
||||||
method: action.name,
|
method,
|
||||||
params: traceParamsForAction(actionInContext),
|
params,
|
||||||
log: [],
|
log: [],
|
||||||
};
|
};
|
||||||
return { callMetadata, mainFrame };
|
return { callMetadata, mainFrame };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext[] {
|
||||||
|
const result: ActionInContext[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type !== 'before')
|
||||||
|
continue;
|
||||||
|
if (!event.stepId?.startsWith('recorder@'))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (event.method === 'goto') {
|
||||||
|
result.push({
|
||||||
|
frame: { pageAlias: 'page', framePath: [] },
|
||||||
|
action: {
|
||||||
|
name: 'navigate',
|
||||||
|
url: event.params.url,
|
||||||
|
signals: [],
|
||||||
|
},
|
||||||
|
timestamp: event.startTime,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.method === 'click') {
|
||||||
|
result.push({
|
||||||
|
frame: { pageAlias: 'page', framePath: [] },
|
||||||
|
action: {
|
||||||
|
name: 'click',
|
||||||
|
selector: event.params.selector,
|
||||||
|
signals: [],
|
||||||
|
button: event.params.button,
|
||||||
|
modifiers: fromKeyboardModifiers(event.params.modifiers),
|
||||||
|
clickCount: event.params.clickCount,
|
||||||
|
position: event.params.position,
|
||||||
|
},
|
||||||
|
timestamp: event.startTime
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.method === 'fill') {
|
||||||
|
result.push({
|
||||||
|
frame: { pageAlias: 'page', framePath: [] },
|
||||||
|
action: {
|
||||||
|
name: 'fill',
|
||||||
|
selector: event.params.selector,
|
||||||
|
signals: [],
|
||||||
|
text: event.params.value,
|
||||||
|
},
|
||||||
|
timestamp: event.startTime
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.method === 'press') {
|
||||||
|
const tokens = event.params.key.split('+');
|
||||||
|
const modifiers = tokens.slice(0, tokens.length - 1);
|
||||||
|
const key = tokens[tokens.length - 1];
|
||||||
|
result.push({
|
||||||
|
frame: { pageAlias: 'page', framePath: [] },
|
||||||
|
action: {
|
||||||
|
name: 'press',
|
||||||
|
selector: event.params.selector,
|
||||||
|
signals: [],
|
||||||
|
key,
|
||||||
|
modifiers: fromKeyboardModifiers(modifiers),
|
||||||
|
},
|
||||||
|
timestamp: event.startTime
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.method === 'check') {
|
||||||
|
result.push({
|
||||||
|
frame: { pageAlias: 'page', framePath: [] },
|
||||||
|
action: {
|
||||||
|
name: 'check',
|
||||||
|
selector: event.params.selector,
|
||||||
|
signals: [],
|
||||||
|
},
|
||||||
|
timestamp: event.startTime
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.method === 'uncheck') {
|
||||||
|
result.push({
|
||||||
|
frame: { pageAlias: 'page', framePath: [] },
|
||||||
|
action: {
|
||||||
|
name: 'uncheck',
|
||||||
|
selector: event.params.selector,
|
||||||
|
signals: [],
|
||||||
|
},
|
||||||
|
timestamp: event.startTime
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.method === 'selectOption') {
|
||||||
|
result.push({
|
||||||
|
frame: { pageAlias: 'page', framePath: [] },
|
||||||
|
action: {
|
||||||
|
name: 'select',
|
||||||
|
selector: event.params.selector,
|
||||||
|
signals: [],
|
||||||
|
options: event.params.options.map((option: any) => option.value),
|
||||||
|
},
|
||||||
|
timestamp: event.startTime
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collapseActions(actions: ActionInContext[]): ActionInContext[] {
|
||||||
|
const result: ActionInContext[] = [];
|
||||||
|
for (const action of actions) {
|
||||||
|
const lastAction = result[result.length - 1];
|
||||||
|
const isSameAction = lastAction && lastAction.action.name === action.action.name && lastAction.frame.pageAlias === action.frame.pageAlias && lastAction.frame.framePath.join('|') === action.frame.framePath.join('|');
|
||||||
|
const isSameSelector = lastAction && 'selector' in lastAction.action && 'selector' in action.action && action.action.selector === lastAction.action.selector;
|
||||||
|
const shouldMerge = isSameAction && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector));
|
||||||
|
if (!shouldMerge) {
|
||||||
|
result.push(action);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[result.length - 1] = action;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export type TracerOptions = {
|
||||||
snapshots?: boolean;
|
snapshots?: boolean;
|
||||||
screenshots?: boolean;
|
screenshots?: boolean;
|
||||||
live?: boolean;
|
live?: boolean;
|
||||||
|
inMemory?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RecordingState = {
|
type RecordingState = {
|
||||||
|
|
@ -79,6 +80,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
||||||
private _allResources = new Set<string>();
|
private _allResources = new Set<string>();
|
||||||
private _contextCreatedEvent: trace.ContextCreatedTraceEvent;
|
private _contextCreatedEvent: trace.ContextCreatedTraceEvent;
|
||||||
private _pendingHarEntries = new Set<har.Entry>();
|
private _pendingHarEntries = new Set<har.Entry>();
|
||||||
|
private _inMemoryEvents: trace.TraceEvent[] | undefined;
|
||||||
|
|
||||||
constructor(context: BrowserContext | APIRequestContext, tracesDir: string | undefined) {
|
constructor(context: BrowserContext | APIRequestContext, tracesDir: string | undefined) {
|
||||||
super(context, 'tracing');
|
super(context, 'tracing');
|
||||||
|
|
@ -153,6 +155,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
||||||
// Tracing is 10x bigger if we include scripts in every trace.
|
// Tracing is 10x bigger if we include scripts in every trace.
|
||||||
if (options.snapshots)
|
if (options.snapshots)
|
||||||
this._harTracer.start({ omitScripts: !options.live });
|
this._harTracer.start({ omitScripts: !options.live });
|
||||||
|
this._inMemoryEvents = options.inMemory ? [] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startChunk(options: { name?: string, title?: string } = {}): Promise<{ traceName: string }> {
|
async startChunk(options: { name?: string, title?: string } = {}): Promise<{ traceName: string }> {
|
||||||
|
|
@ -179,7 +182,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
||||||
wallTime: Date.now(),
|
wallTime: Date.now(),
|
||||||
monotonicTime: monotonicTime()
|
monotonicTime: monotonicTime()
|
||||||
};
|
};
|
||||||
this._fs.appendFile(this._state.traceFile, JSON.stringify(event) + '\n');
|
this._appendTraceEvent(event);
|
||||||
|
|
||||||
this._context.instrumentation.addListener(this, this._context);
|
this._context.instrumentation.addListener(this, this._context);
|
||||||
this._eventListeners.push(
|
this._eventListeners.push(
|
||||||
|
|
@ -193,6 +196,10 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
||||||
return { traceName: this._state.traceName };
|
return { traceName: this._state.traceName };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inMemoryEvents(): trace.TraceEvent[] {
|
||||||
|
return this._inMemoryEvents || [];
|
||||||
|
}
|
||||||
|
|
||||||
private _startScreencast() {
|
private _startScreencast() {
|
||||||
if (!(this._context instanceof BrowserContext))
|
if (!(this._context instanceof BrowserContext))
|
||||||
return;
|
return;
|
||||||
|
|
@ -487,6 +494,8 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
||||||
// Do not flush (console) events, they are too noisy, unless we are in ui mode (live).
|
// Do not flush (console) events, they are too noisy, unless we are in ui mode (live).
|
||||||
const flush = this._state!.options.live || (event.type !== 'event' && event.type !== 'console' && event.type !== 'log');
|
const flush = this._state!.options.live || (event.type !== 'event' && event.type !== 'console' && event.type !== 'log');
|
||||||
this._fs.appendFile(this._state!.traceFile, JSON.stringify(visited) + '\n', flush);
|
this._fs.appendFile(this._state!.traceFile, JSON.stringify(visited) + '\n', flush);
|
||||||
|
if (this._inMemoryEvents)
|
||||||
|
this._inMemoryEvents.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _appendResource(sha1: string, buffer: Buffer) {
|
private _appendResource(sha1: string, buffer: Buffer) {
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ export async function openTraceViewerApp(url: string, browserName: string, optio
|
||||||
...options?.persistentContextOptions,
|
...options?.persistentContextOptions,
|
||||||
useWebSocket: isUnderTest(),
|
useWebSocket: isUnderTest(),
|
||||||
headless: !!options?.headless,
|
headless: !!options?.headless,
|
||||||
|
args: process.env.PWTEST_RECORDER_PORT ? [`--remote-debugging-port=${process.env.PWTEST_RECORDER_PORT}`] : [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export class TraceModel {
|
||||||
unzipProgress(++done, total);
|
unzipProgress(++done, total);
|
||||||
|
|
||||||
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
||||||
|
|
||||||
if (!backend.isLive()) {
|
if (!backend.isLive()) {
|
||||||
// Terminate actions w/o after event gracefully.
|
// Terminate actions w/o after event gracefully.
|
||||||
// This would close after hooks event that has not been closed because
|
// This would close after hooks event that has not been closed because
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export const RecorderView: React.FunctionComponent = () => {
|
||||||
connection.setMode('recording');
|
connection.setMode('recording');
|
||||||
}, [connection]);
|
}, [connection]);
|
||||||
|
|
||||||
|
window.playwrightSourcesEchoForTest = sources;
|
||||||
|
|
||||||
return <div className='vbox workbench-loader'>
|
return <div className='vbox workbench-loader'>
|
||||||
<TraceView
|
<TraceView
|
||||||
traceLocation={trace}
|
traceLocation={trace}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export const test = contextTest.extend<CLITestArgs>({
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
while (!toImpl(context).recorderAppForTest)
|
while (!toImpl(context).recorderAppForTest)
|
||||||
await new Promise(f => setTimeout(f, 100));
|
await new Promise(f => setTimeout(f, 100));
|
||||||
const wsEndpoint = toImpl(context).recorderAppForTest.wsEndpoint;
|
const wsEndpoint = toImpl(context).recorderAppForTest.wsEndpointForTest;
|
||||||
const browser = await playwrightToAutomateInspector.chromium.connectOverCDP({ wsEndpoint });
|
const browser = await playwrightToAutomateInspector.chromium.connectOverCDP({ wsEndpoint });
|
||||||
const c = browser.contexts()[0];
|
const c = browser.contexts()[0];
|
||||||
return c.pages()[0] || await c.waitForEvent('page');
|
return c.pages()[0] || await c.waitForEvent('page');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue