chore: render recorded action list in tv mode (#32841)
This commit is contained in:
parent
5b85c71722
commit
1a3d3f699b
|
|
@ -75,7 +75,7 @@ export class ContextRecorder extends EventEmitter {
|
||||||
saveStorage: params.saveStorage,
|
saveStorage: params.saveStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
this._collection = new RecorderCollection(codegenMode, context, this._pageAliases);
|
this._collection = new RecorderCollection(this._pageAliases);
|
||||||
this._collection.on('change', (actions: actions.ActionInContext[]) => {
|
this._collection.on('change', (actions: actions.ActionInContext[]) => {
|
||||||
this._recorderSources = [];
|
this._recorderSources = [];
|
||||||
for (const languageGenerator of this._orderedLanguages) {
|
for (const languageGenerator of this._orderedLanguages) {
|
||||||
|
|
|
||||||
|
|
@ -20,30 +20,20 @@ import type { Page } from '../page';
|
||||||
import type { Signal } from '../../../../recorder/src/actions';
|
import type { Signal } from '../../../../recorder/src/actions';
|
||||||
import type * as actions from '@recorder/actions';
|
import type * as actions from '@recorder/actions';
|
||||||
import { monotonicTime } from '../../utils/time';
|
import { monotonicTime } from '../../utils/time';
|
||||||
import { callMetadataForAction, collapseActions, traceEventsToAction } from './recorderUtils';
|
import { callMetadataForAction, collapseActions } 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: actions.ActionInContext[] = [];
|
private _actions: actions.ActionInContext[] = [];
|
||||||
private _enabled = false;
|
private _enabled = false;
|
||||||
private _pageAliases: Map<Page, string>;
|
private _pageAliases: Map<Page, string>;
|
||||||
private _context: BrowserContext;
|
|
||||||
|
|
||||||
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, pageAliases: Map<Page, string>) {
|
constructor(pageAliases: Map<Page, string>) {
|
||||||
super();
|
super();
|
||||||
this._context = context;
|
|
||||||
this._pageAliases = pageAliases;
|
this._pageAliases = pageAliases;
|
||||||
|
|
||||||
if (codegenMode === 'trace-events') {
|
|
||||||
this._context.tracing.onMemoryEvents(events => {
|
|
||||||
this._actions = traceEventsToAction(events);
|
|
||||||
this._fireChange();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
restart() {
|
restart() {
|
||||||
|
|
@ -86,7 +76,8 @@ export class RecorderCollection extends EventEmitter {
|
||||||
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;
|
||||||
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
|
// Do not wait for onAfterCall so that performAction returned immediately after the action.
|
||||||
|
mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
signal(pageAlias: string, frame: Frame, signal: Signal) {
|
signal(pageAlias: string, frame: Frame, signal: Signal) {
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,10 @@ import type { Page } from '../page';
|
||||||
import type { Frame } from '../frames';
|
import type { Frame } from '../frames';
|
||||||
import type * as actions from '@recorder/actions';
|
import type * as actions from '@recorder/actions';
|
||||||
import type * as channels from '@protocol/channels';
|
import type * as channels from '@protocol/channels';
|
||||||
import type * as trace from '@trace/trace';
|
import { toKeyboardModifiers } from '../codegen/language';
|
||||||
import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language';
|
|
||||||
import { serializeExpectedTextValues } from '../../utils/expectUtils';
|
import { serializeExpectedTextValues } from '../../utils/expectUtils';
|
||||||
import { createGuid, monotonicTime } from '../../utils';
|
import { createGuid } from '../../utils';
|
||||||
import { parseSerializedValue, serializeValue } from '../../protocol/serializers';
|
import { serializeValue } from '../../protocol/serializers';
|
||||||
import type { SmartKeyboardModifier } from '../types';
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -201,7 +199,7 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
|
||||||
objectId: mainFrame.guid,
|
objectId: mainFrame.guid,
|
||||||
pageId: mainFrame._page.guid,
|
pageId: mainFrame._page.guid,
|
||||||
frameId: mainFrame.guid,
|
frameId: mainFrame.guid,
|
||||||
startTime: monotonicTime(),
|
startTime: actionInContext.timestamp,
|
||||||
endTime: 0,
|
endTime: 0,
|
||||||
type: 'Frame',
|
type: 'Frame',
|
||||||
method,
|
method,
|
||||||
|
|
@ -211,281 +209,6 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
|
||||||
return { callMetadata, mainFrame };
|
return { callMetadata, mainFrame };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function traceEventsToAction(events: trace.TraceEvent[]): actions.ActionInContext[] {
|
|
||||||
const result: actions.ActionInContext[] = [];
|
|
||||||
const pageAliases = new Map<string, string>();
|
|
||||||
let lastDownloadOrdinal = 0;
|
|
||||||
let lastDialogOrdinal = 0;
|
|
||||||
|
|
||||||
const addSignal = (signal: actions.Signal) => {
|
|
||||||
const lastAction = result[result.length - 1];
|
|
||||||
if (!lastAction)
|
|
||||||
return;
|
|
||||||
lastAction.action.signals.push(signal);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
if (event.type === 'event' && event.class === 'BrowserContext') {
|
|
||||||
const { method, params } = event;
|
|
||||||
if (method === 'page') {
|
|
||||||
const pageAlias = 'page' + (pageAliases.size || '');
|
|
||||||
pageAliases.set(params.pageId, pageAlias);
|
|
||||||
addSignal({
|
|
||||||
name: 'popup',
|
|
||||||
popupAlias: pageAlias,
|
|
||||||
});
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'openPage',
|
|
||||||
url: '',
|
|
||||||
signals: [],
|
|
||||||
},
|
|
||||||
timestamp: event.time,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'pageClosed') {
|
|
||||||
const pageAlias = pageAliases.get(event.params.pageId) || 'page';
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'closePage',
|
|
||||||
signals: [],
|
|
||||||
},
|
|
||||||
timestamp: event.time,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'download') {
|
|
||||||
const downloadAlias = lastDownloadOrdinal ? String(lastDownloadOrdinal) : '';
|
|
||||||
++lastDownloadOrdinal;
|
|
||||||
addSignal({
|
|
||||||
name: 'download',
|
|
||||||
downloadAlias,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'dialog') {
|
|
||||||
const dialogAlias = lastDialogOrdinal ? String(lastDialogOrdinal) : '';
|
|
||||||
++lastDialogOrdinal;
|
|
||||||
addSignal({
|
|
||||||
name: 'dialog',
|
|
||||||
dialogAlias,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type !== 'before' || !event.pageId)
|
|
||||||
continue;
|
|
||||||
if (!event.stepId?.startsWith('recorder@'))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const { method, params: untypedParams, pageId } = event;
|
|
||||||
|
|
||||||
let pageAlias = pageAliases.get(pageId);
|
|
||||||
if (!pageAlias) {
|
|
||||||
pageAlias = 'page';
|
|
||||||
pageAliases.set(pageId, pageAlias);
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'openPage',
|
|
||||||
url: '',
|
|
||||||
signals: [],
|
|
||||||
},
|
|
||||||
timestamp: event.startTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'goto') {
|
|
||||||
const params = untypedParams as channels.FrameGotoParams;
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'navigate',
|
|
||||||
url: params.url,
|
|
||||||
signals: [],
|
|
||||||
},
|
|
||||||
timestamp: event.startTime,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'click') {
|
|
||||||
const params = untypedParams as channels.FrameClickParams;
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'click',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
button: params.button || 'left',
|
|
||||||
modifiers: fromKeyboardModifiers(params.modifiers),
|
|
||||||
clickCount: params.clickCount || 1,
|
|
||||||
position: params.position,
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (method === 'fill') {
|
|
||||||
const params = untypedParams as channels.FrameFillParams;
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'fill',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
text: params.value,
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (method === 'press') {
|
|
||||||
const params = untypedParams as channels.FramePressParams;
|
|
||||||
const tokens = params.key.split('+');
|
|
||||||
const modifiers = tokens.slice(0, tokens.length - 1) as SmartKeyboardModifier[];
|
|
||||||
const key = tokens[tokens.length - 1];
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'press',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
key,
|
|
||||||
modifiers: fromKeyboardModifiers(modifiers),
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (method === 'check') {
|
|
||||||
const params = untypedParams as channels.FrameCheckParams;
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'check',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (method === 'uncheck') {
|
|
||||||
const params = untypedParams as channels.FrameUncheckParams;
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'uncheck',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (method === 'selectOption') {
|
|
||||||
const params = untypedParams as channels.FrameSelectOptionParams;
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'select',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
options: (params.options || []).map(option => option.value!),
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (method === 'setInputFiles') {
|
|
||||||
const params = untypedParams as channels.FrameSetInputFilesParams;
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'setInputFiles',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
files: params.localPaths || [],
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (method === 'expect') {
|
|
||||||
const params = untypedParams as channels.FrameExpectParams;
|
|
||||||
if (params.expression === 'to.have.text') {
|
|
||||||
const entry = params.expectedText?.[0];
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'assertText',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
text: entry?.string!,
|
|
||||||
substring: !!entry?.matchSubstring,
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.expression === 'to.have.value') {
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'assertValue',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
value: parseSerializedValue(params.expectedValue!.value, params.expectedValue!.handles),
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.expression === 'to.be.checked') {
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'assertChecked',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
checked: !params.isNot,
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.expression === 'to.be.visible') {
|
|
||||||
result.push({
|
|
||||||
frame: { pageAlias, framePath: [] },
|
|
||||||
action: {
|
|
||||||
name: 'assertVisible',
|
|
||||||
selector: params.selector,
|
|
||||||
signals: [],
|
|
||||||
},
|
|
||||||
timestamp: event.startTime
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collapseActions(actions: actions.ActionInContext[]): actions.ActionInContext[] {
|
export function collapseActions(actions: actions.ActionInContext[]): actions.ActionInContext[] {
|
||||||
const result: actions.ActionInContext[] = [];
|
const result: actions.ActionInContext[] = [];
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,7 @@ ui/
|
||||||
|
|
||||||
[sw-main.ts]
|
[sw-main.ts]
|
||||||
sw/**
|
sw/**
|
||||||
|
|
||||||
|
|
||||||
|
[recorder.tsx]
|
||||||
|
ui/recorder/**
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import '@web/common.css';
|
||||||
import { applyTheme } from '@web/theme';
|
import { applyTheme } from '@web/theme';
|
||||||
import '@web/third_party/vscode/codicon.css';
|
import '@web/third_party/vscode/codicon.css';
|
||||||
import * as ReactDOM from 'react-dom/client';
|
import * as ReactDOM from 'react-dom/client';
|
||||||
import { RecorderView } from './ui/recorderView';
|
import { RecorderView } from './ui/recorder/recorderView';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
applyTheme();
|
applyTheme();
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,12 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
|
||||||
}
|
}
|
||||||
set.add(traceUrl);
|
set.add(traceUrl);
|
||||||
|
|
||||||
const isRecorderMode = traceUrl.includes('/playwright-recorder-trace-');
|
|
||||||
|
|
||||||
const traceModel = new TraceModel();
|
const traceModel = new TraceModel();
|
||||||
try {
|
try {
|
||||||
// Allow 10% to hop from sw to page.
|
// Allow 10% to hop from sw to page.
|
||||||
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
|
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
|
||||||
const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress);
|
const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress);
|
||||||
await traceModel.load(backend, isRecorderMode, unzipProgress);
|
await traceModel.load(backend, unzipProgress);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseClientSideCallMetadata } from '@isomorphic/traceUtils';
|
import { parseClientSideCallMetadata } from '@isomorphic/traceUtils';
|
||||||
import type { ActionEntry, ContextEntry } from '../types/entries';
|
import type { ContextEntry } from '../types/entries';
|
||||||
import { SnapshotStorage } from './snapshotStorage';
|
import { SnapshotStorage } from './snapshotStorage';
|
||||||
import { TraceModernizer } from './traceModernizer';
|
import { TraceModernizer } from './traceModernizer';
|
||||||
|
|
||||||
|
|
@ -37,7 +37,7 @@ export class TraceModel {
|
||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(backend: TraceModelBackend, isRecorderMode: boolean, unzipProgress: (done: number, total: number) => void) {
|
async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) {
|
||||||
this._backend = backend;
|
this._backend = backend;
|
||||||
|
|
||||||
const ordinals: string[] = [];
|
const ordinals: string[] = [];
|
||||||
|
|
@ -71,8 +71,7 @@ export class TraceModel {
|
||||||
modernizer.appendTrace(network);
|
modernizer.appendTrace(network);
|
||||||
unzipProgress(++done, total);
|
unzipProgress(++done, total);
|
||||||
|
|
||||||
const actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
||||||
contextEntry.actions = isRecorderMode ? collapseActionsForRecorder(actions) : actions;
|
|
||||||
|
|
||||||
if (!backend.isLive()) {
|
if (!backend.isLive()) {
|
||||||
// Terminate actions w/o after event gracefully.
|
// Terminate actions w/o after event gracefully.
|
||||||
|
|
@ -134,22 +133,6 @@ function stripEncodingFromContentType(contentType: string) {
|
||||||
return contentType;
|
return contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseActionsForRecorder(actions: ActionEntry[]): ActionEntry[] {
|
|
||||||
const result: ActionEntry[] = [];
|
|
||||||
for (const action of actions) {
|
|
||||||
const lastAction = result[result.length - 1];
|
|
||||||
const isSameAction = lastAction && lastAction.method === action.method && lastAction.pageId === action.pageId;
|
|
||||||
const isSameSelector = lastAction && 'selector' in lastAction.params && 'selector' in action.params && action.params.selector === lastAction.params.selector;
|
|
||||||
const shouldMerge = isSameAction && (action.method === 'goto' || (action.method === 'fill' && isSameSelector));
|
|
||||||
if (!shouldMerge) {
|
|
||||||
result.push(action);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result[result.length - 1] = action;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptyContext(): ContextEntry {
|
function createEmptyContext(): ContextEntry {
|
||||||
return {
|
return {
|
||||||
origin: 'testRunner',
|
origin: 'testRunner',
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,9 @@ export function useConsoleTabModel(model: modelUtil.MultiTraceModel | undefined,
|
||||||
export const ConsoleTab: React.FunctionComponent<{
|
export const ConsoleTab: React.FunctionComponent<{
|
||||||
boundaries: Boundaries,
|
boundaries: Boundaries,
|
||||||
consoleModel: ConsoleTabModel,
|
consoleModel: ConsoleTabModel,
|
||||||
selectedTime: Boundaries | undefined,
|
selectedTime?: Boundaries | undefined,
|
||||||
onEntryHovered?: (entry: ConsoleEntry | undefined) => void,
|
onEntryHovered?: (entry: ConsoleEntry | undefined) => void,
|
||||||
onAccepted: (entry: ConsoleEntry) => void,
|
onAccepted?: (entry: ConsoleEntry) => void,
|
||||||
}> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => {
|
}> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => {
|
||||||
if (!consoleModel.entries.length)
|
if (!consoleModel.entries.length)
|
||||||
return <PlaceholderPanel text='No console entries' />;
|
return <PlaceholderPanel text='No console entries' />;
|
||||||
|
|
|
||||||
5
packages/trace-viewer/src/ui/recorder/DEPS.list
Normal file
5
packages/trace-viewer/src/ui/recorder/DEPS.list
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[*]
|
||||||
|
@isomorphic/**
|
||||||
|
@trace/**
|
||||||
|
@web/**
|
||||||
|
../**
|
||||||
48
packages/trace-viewer/src/ui/recorder/actionListView.tsx
Normal file
48
packages/trace-viewer/src/ui/recorder/actionListView.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as actionTypes from '@recorder/actions';
|
||||||
|
import { ListView } from '@web/components/listView';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const ActionList = ListView<actionTypes.ActionInContext>;
|
||||||
|
|
||||||
|
export const ActionListView: React.FC<{
|
||||||
|
actions: actionTypes.ActionInContext[],
|
||||||
|
selectedAction: actionTypes.ActionInContext | undefined,
|
||||||
|
onSelectedAction: (action: actionTypes.ActionInContext | undefined) => void,
|
||||||
|
}> = ({
|
||||||
|
actions,
|
||||||
|
selectedAction,
|
||||||
|
onSelectedAction,
|
||||||
|
}) => {
|
||||||
|
return <div className='vbox'>
|
||||||
|
<ActionList
|
||||||
|
name='actions'
|
||||||
|
items={actions}
|
||||||
|
selectedItem={selectedAction}
|
||||||
|
onSelected={onSelectedAction}
|
||||||
|
render={renderAction} />
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderAction = (action: actionTypes.ActionInContext) => {
|
||||||
|
return <>
|
||||||
|
<div title={action.action.name}>
|
||||||
|
<span>{action.action.name}</span>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
124
packages/trace-viewer/src/ui/recorder/backendContext.tsx
Normal file
124
packages/trace-viewer/src/ui/recorder/backendContext.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/*
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as actionTypes from '@recorder/actions';
|
||||||
|
import type { Mode, Source } from '@recorder/recorderTypes';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export const BackendContext = React.createContext<Backend | undefined>(undefined);
|
||||||
|
|
||||||
|
export const BackendProvider: React.FunctionComponent<React.PropsWithChildren<{
|
||||||
|
guid: string,
|
||||||
|
}>> = ({ guid, children }) => {
|
||||||
|
const [connection, setConnection] = React.useState<Connection | undefined>(undefined);
|
||||||
|
const [mode, setMode] = React.useState<Mode>('none');
|
||||||
|
const [actions, setActions] = React.useState<actionTypes.ActionInContext[]>([]);
|
||||||
|
const [sources, setSources] = React.useState<Source[]>([]);
|
||||||
|
const callbacks = React.useRef({ setMode, setActions, setSources });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const wsURL = new URL(`../${guid}`, window.location.toString());
|
||||||
|
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
||||||
|
const webSocket = new WebSocket(wsURL.toString());
|
||||||
|
setConnection(new Connection(webSocket, callbacks.current));
|
||||||
|
return () => {
|
||||||
|
webSocket.close();
|
||||||
|
};
|
||||||
|
}, [guid]);
|
||||||
|
|
||||||
|
const backend = React.useMemo(() => {
|
||||||
|
return connection ? { mode, actions, sources, connection } : undefined;
|
||||||
|
}, [actions, mode, sources, connection]);
|
||||||
|
|
||||||
|
return <BackendContext.Provider value={backend}>
|
||||||
|
{children}
|
||||||
|
</BackendContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Backend = {
|
||||||
|
actions: actionTypes.ActionInContext[],
|
||||||
|
sources: Source[],
|
||||||
|
connection: Connection,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConnectionCallbacks = {
|
||||||
|
setMode: (mode: Mode) => void;
|
||||||
|
setActions: (actions: actionTypes.ActionInContext[]) => void;
|
||||||
|
setSources: (sources: Source[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Connection {
|
||||||
|
private _lastId = 0;
|
||||||
|
private _webSocket: WebSocket;
|
||||||
|
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
|
||||||
|
private _options: ConnectionCallbacks;
|
||||||
|
|
||||||
|
constructor(webSocket: WebSocket, options: ConnectionCallbacks) {
|
||||||
|
this._webSocket = webSocket;
|
||||||
|
this._callbacks = new Map();
|
||||||
|
this._options = options;
|
||||||
|
|
||||||
|
this._webSocket.addEventListener('message', event => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
const { id, result, error, method, params } = message;
|
||||||
|
if (id) {
|
||||||
|
const callback = this._callbacks.get(id);
|
||||||
|
if (!callback)
|
||||||
|
return;
|
||||||
|
this._callbacks.delete(id);
|
||||||
|
if (error)
|
||||||
|
callback.reject(new Error(error));
|
||||||
|
else
|
||||||
|
callback.resolve(result);
|
||||||
|
} else {
|
||||||
|
this._dispatchEvent(method, params);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode: Mode) {
|
||||||
|
this._sendMessageNoReply('setMode', { mode });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _sendMessage(method: string, params?: any): Promise<any> {
|
||||||
|
const id = ++this._lastId;
|
||||||
|
const message = { id, method, params };
|
||||||
|
this._webSocket.send(JSON.stringify(message));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._callbacks.set(id, { resolve, reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendMessageNoReply(method: string, params?: any) {
|
||||||
|
this._sendMessage(method, params).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dispatchEvent(method: string, params?: any) {
|
||||||
|
if (method === 'setMode') {
|
||||||
|
const { mode } = params as { mode: Mode };
|
||||||
|
this._options.setMode(mode);
|
||||||
|
}
|
||||||
|
if (method === 'setSources') {
|
||||||
|
const { sources } = params as { sources: Source[] };
|
||||||
|
this._options.setSources(sources);
|
||||||
|
(window as any).playwrightSourcesEchoForTest = sources;
|
||||||
|
}
|
||||||
|
if (method === 'setActions') {
|
||||||
|
const { actions } = params as { actions: actionTypes.ActionInContext[] };
|
||||||
|
this._options.setActions(actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
packages/trace-viewer/src/ui/recorder/modelContext.tsx
Normal file
70
packages/trace-viewer/src/ui/recorder/modelContext.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
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 { sha1 } from '@web/uiUtils';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { ContextEntry } from '../../types/entries';
|
||||||
|
import { MultiTraceModel } from '../modelUtil';
|
||||||
|
|
||||||
|
export const ModelContext = React.createContext<MultiTraceModel | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ModelProvider: React.FunctionComponent<React.PropsWithChildren<{
|
||||||
|
trace: string,
|
||||||
|
}>> = ({ trace, children }) => {
|
||||||
|
const [model, setModel] = React.useState<{ model: MultiTraceModel, sha1: string } | undefined>();
|
||||||
|
const [counter, setCounter] = React.useState(0);
|
||||||
|
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (pollTimer.current)
|
||||||
|
clearTimeout(pollTimer.current);
|
||||||
|
|
||||||
|
// Start polling running test.
|
||||||
|
pollTimer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await loadSingleTraceFile(trace);
|
||||||
|
if (result.sha1 !== model?.sha1)
|
||||||
|
setModel(result);
|
||||||
|
} catch {
|
||||||
|
setModel(undefined);
|
||||||
|
} finally {
|
||||||
|
setCounter(counter + 1);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return () => {
|
||||||
|
if (pollTimer.current)
|
||||||
|
clearTimeout(pollTimer.current);
|
||||||
|
};
|
||||||
|
}, [counter, model, trace]);
|
||||||
|
|
||||||
|
return <ModelContext.Provider value={model?.model}>
|
||||||
|
{children}
|
||||||
|
</ModelContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadSingleTraceFile(url: string): Promise<{ model: MultiTraceModel, sha1: string }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('trace', url);
|
||||||
|
const response = await fetch(`contexts?${params.toString()}`);
|
||||||
|
const contextEntries = await response.json() as ContextEntry[];
|
||||||
|
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const entry of contextEntries) {
|
||||||
|
entry.actions.forEach(a => tokens.push(a.type + '@' + a.startTime + '-' + a.endTime));
|
||||||
|
entry.events.forEach(e => tokens.push(e.type + '@' + e.time));
|
||||||
|
}
|
||||||
|
return { model: new MultiTraceModel(contextEntries), sha1: await sha1(tokens.join('|')) };
|
||||||
|
}
|
||||||
269
packages/trace-viewer/src/ui/recorder/recorderView.tsx
Normal file
269
packages/trace-viewer/src/ui/recorder/recorderView.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
/*
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as actionTypes from '@recorder/actions';
|
||||||
|
import { SourceChooser } from '@web/components/sourceChooser';
|
||||||
|
import { SplitView } from '@web/components/splitView';
|
||||||
|
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
|
||||||
|
import { TabbedPane } from '@web/components/tabbedPane';
|
||||||
|
import { Toolbar } from '@web/components/toolbar';
|
||||||
|
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
|
||||||
|
import { toggleTheme } from '@web/theme';
|
||||||
|
import { copy, useSetting } from '@web/uiUtils';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ConsoleTab, useConsoleTabModel } from '../consoleTab';
|
||||||
|
import type { Boundaries } from '../geometry';
|
||||||
|
import { InspectorTab } from '../inspectorTab';
|
||||||
|
import type * as modelUtil from '../modelUtil';
|
||||||
|
import type { SourceLocation } from '../modelUtil';
|
||||||
|
import { NetworkTab, useNetworkTabModel } from '../networkTab';
|
||||||
|
import { collectSnapshots, extendSnapshot, SnapshotView } from '../snapshotTab';
|
||||||
|
import { SourceTab } from '../sourceTab';
|
||||||
|
import { ModelContext, ModelProvider } from './modelContext';
|
||||||
|
import './recorderView.css';
|
||||||
|
import { ActionListView } from './actionListView';
|
||||||
|
import { BackendContext, BackendProvider } from './backendContext';
|
||||||
|
|
||||||
|
export const RecorderView: React.FunctionComponent = () => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const guid = searchParams.get('ws')!;
|
||||||
|
const trace = searchParams.get('trace') + '.json';
|
||||||
|
|
||||||
|
return <BackendProvider guid={guid}>
|
||||||
|
<ModelProvider trace={trace}>
|
||||||
|
<Workbench />
|
||||||
|
</ModelProvider>
|
||||||
|
</BackendProvider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Workbench: React.FunctionComponent = () => {
|
||||||
|
const backend = React.useContext(BackendContext);
|
||||||
|
const model = React.useContext(ModelContext);
|
||||||
|
const [fileId, setFileId] = React.useState<string | undefined>();
|
||||||
|
const [selectedCallTime, setSelectedCallTime] = React.useState<number | undefined>(undefined);
|
||||||
|
const [isInspecting, setIsInspecting] = React.useState(false);
|
||||||
|
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
|
||||||
|
|
||||||
|
const setSelectedAction = React.useCallback((action: actionTypes.ActionInContext | undefined) => {
|
||||||
|
setSelectedCallTime(action?.timestamp);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedAction = React.useMemo(() => {
|
||||||
|
return backend?.actions.find(a => a.timestamp === selectedCallTime);
|
||||||
|
}, [backend?.actions, selectedCallTime]);
|
||||||
|
|
||||||
|
const source = React.useMemo(() => backend?.sources.find(s => s.id === fileId) || backend?.sources[0], [backend?.sources, fileId]);
|
||||||
|
const sourceLocation = React.useMemo(() => {
|
||||||
|
if (!source)
|
||||||
|
return undefined;
|
||||||
|
const sourceLocation: SourceLocation = {
|
||||||
|
file: '',
|
||||||
|
line: 0,
|
||||||
|
column: 0,
|
||||||
|
source: {
|
||||||
|
errors: [],
|
||||||
|
content: source.text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return sourceLocation;
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
const { boundaries } = React.useMemo(() => {
|
||||||
|
const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 };
|
||||||
|
if (boundaries.minimum > boundaries.maximum) {
|
||||||
|
boundaries.minimum = 0;
|
||||||
|
boundaries.maximum = 30000;
|
||||||
|
}
|
||||||
|
// Leave some nice free space on the right hand side.
|
||||||
|
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
|
||||||
|
return { boundaries };
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
const actionList = <ActionListView
|
||||||
|
actions={backend?.actions || []}
|
||||||
|
selectedAction={selectedAction}
|
||||||
|
onSelectedAction={setSelectedAction}
|
||||||
|
/>;
|
||||||
|
|
||||||
|
const actionsTab: TabbedPaneTabModel = {
|
||||||
|
id: 'actions',
|
||||||
|
title: 'Actions',
|
||||||
|
component: actionList,
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbar = <Toolbar sidebarBackground>
|
||||||
|
<div style={{ width: 4 }}></div>
|
||||||
|
<ToolbarButton icon='inspect' title='Pick locator' toggled={isInspecting} onClick={() => {
|
||||||
|
setIsInspecting(!isInspecting);
|
||||||
|
}} />
|
||||||
|
<ToolbarButton icon='eye' title='Assert visibility' onClick={() => {
|
||||||
|
}} />
|
||||||
|
<ToolbarButton icon='whole-word' title='Assert text' onClick={() => {
|
||||||
|
}} />
|
||||||
|
<ToolbarButton icon='symbol-constant' title='Assert value' onClick={() => {
|
||||||
|
}} />
|
||||||
|
<ToolbarSeparator />
|
||||||
|
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
|
||||||
|
if (source?.text)
|
||||||
|
copy(source.text);
|
||||||
|
}}></ToolbarButton>
|
||||||
|
<div style={{ flex: 'auto' }}></div>
|
||||||
|
<div>Target:</div>
|
||||||
|
<SourceChooser fileId={fileId} sources={backend?.sources || []} setFileId={fileId => {
|
||||||
|
setFileId(fileId);
|
||||||
|
}} />
|
||||||
|
<ToolbarButton icon='clear-all' title='Clear' onClick={() => {
|
||||||
|
}}></ToolbarButton>
|
||||||
|
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
|
||||||
|
</Toolbar>;
|
||||||
|
|
||||||
|
const sidebarTabbedPane = <TabbedPane tabs={[actionsTab]} />;
|
||||||
|
const traceView = <TraceView
|
||||||
|
callTime={selectedCallTime || 0}
|
||||||
|
isInspecting={isInspecting}
|
||||||
|
setIsInspecting={setIsInspecting}
|
||||||
|
highlightedLocator={highlightedLocator}
|
||||||
|
setHighlightedLocator={setHighlightedLocator} />;
|
||||||
|
const propertiesView = <PropertiesView
|
||||||
|
boundaries={boundaries}
|
||||||
|
setIsInspecting={setIsInspecting}
|
||||||
|
highlightedLocator={highlightedLocator}
|
||||||
|
setHighlightedLocator={setHighlightedLocator}
|
||||||
|
sourceLocation={sourceLocation} />;
|
||||||
|
|
||||||
|
return <div className='vbox workbench'>
|
||||||
|
<SplitView
|
||||||
|
sidebarSize={250}
|
||||||
|
orientation={'horizontal'}
|
||||||
|
settingName='recorderActionListSidebar'
|
||||||
|
sidebarIsFirst
|
||||||
|
main={<SplitView
|
||||||
|
sidebarSize={250}
|
||||||
|
orientation='vertical'
|
||||||
|
settingName='recorderPropertiesSidebar'
|
||||||
|
main={<div className='vbox'>
|
||||||
|
{toolbar}
|
||||||
|
{traceView}
|
||||||
|
</div>}
|
||||||
|
sidebar={propertiesView}
|
||||||
|
/>}
|
||||||
|
sidebar={sidebarTabbedPane}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PropertiesView: React.FunctionComponent<{
|
||||||
|
boundaries: Boundaries,
|
||||||
|
setIsInspecting: (value: boolean) => void,
|
||||||
|
highlightedLocator: string,
|
||||||
|
setHighlightedLocator: (locator: string) => void,
|
||||||
|
sourceLocation: modelUtil.SourceLocation | undefined,
|
||||||
|
}> = ({
|
||||||
|
boundaries,
|
||||||
|
setIsInspecting,
|
||||||
|
highlightedLocator,
|
||||||
|
setHighlightedLocator,
|
||||||
|
sourceLocation,
|
||||||
|
}) => {
|
||||||
|
const model = React.useContext(ModelContext);
|
||||||
|
const consoleModel = useConsoleTabModel(model, boundaries);
|
||||||
|
const networkModel = useNetworkTabModel(model, boundaries);
|
||||||
|
const sourceModel = React.useRef(new Map<string, modelUtil.SourceModel>());
|
||||||
|
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('recorderPropertiesTab', 'source');
|
||||||
|
|
||||||
|
const sdkLanguage = model?.sdkLanguage || 'javascript';
|
||||||
|
|
||||||
|
const inspectorTab: TabbedPaneTabModel = {
|
||||||
|
id: 'inspector',
|
||||||
|
title: 'Locator',
|
||||||
|
render: () => <InspectorTab
|
||||||
|
sdkLanguage={sdkLanguage}
|
||||||
|
setIsInspecting={setIsInspecting}
|
||||||
|
highlightedLocator={highlightedLocator}
|
||||||
|
setHighlightedLocator={setHighlightedLocator} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceTab: TabbedPaneTabModel = {
|
||||||
|
id: 'source',
|
||||||
|
title: 'Source',
|
||||||
|
render: () => <SourceTab
|
||||||
|
sources={sourceModel.current}
|
||||||
|
stackFrameLocation={'right'}
|
||||||
|
fallbackLocation={sourceLocation}
|
||||||
|
/>
|
||||||
|
};
|
||||||
|
const consoleTab: TabbedPaneTabModel = {
|
||||||
|
id: 'console',
|
||||||
|
title: 'Console',
|
||||||
|
count: consoleModel.entries.length,
|
||||||
|
render: () => <ConsoleTab boundaries={boundaries} consoleModel={consoleModel} />
|
||||||
|
};
|
||||||
|
const networkTab: TabbedPaneTabModel = {
|
||||||
|
id: 'network',
|
||||||
|
title: 'Network',
|
||||||
|
count: networkModel.resources.length,
|
||||||
|
render: () => <NetworkTab boundaries={boundaries} networkModel={networkModel} />
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: TabbedPaneTabModel[] = [
|
||||||
|
sourceTab,
|
||||||
|
inspectorTab,
|
||||||
|
consoleTab,
|
||||||
|
networkTab,
|
||||||
|
];
|
||||||
|
|
||||||
|
return <TabbedPane
|
||||||
|
tabs={tabs}
|
||||||
|
selectedTab={selectedPropertiesTab}
|
||||||
|
setSelectedTab={setSelectedPropertiesTab}
|
||||||
|
/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TraceView: React.FunctionComponent<{
|
||||||
|
callTime: number;
|
||||||
|
isInspecting: boolean;
|
||||||
|
setIsInspecting: (value: boolean) => void;
|
||||||
|
highlightedLocator: string;
|
||||||
|
setHighlightedLocator: (locator: string) => void;
|
||||||
|
}> = ({
|
||||||
|
callTime,
|
||||||
|
isInspecting,
|
||||||
|
setIsInspecting,
|
||||||
|
highlightedLocator,
|
||||||
|
setHighlightedLocator,
|
||||||
|
}) => {
|
||||||
|
const model = React.useContext(ModelContext);
|
||||||
|
const action = React.useMemo(() => {
|
||||||
|
return model?.actions.find(a => a.startTime === callTime);
|
||||||
|
}, [model, callTime]);
|
||||||
|
|
||||||
|
const snapshot = React.useMemo(() => {
|
||||||
|
const snapshot = collectSnapshots(action);
|
||||||
|
return snapshot.action || snapshot.after || snapshot.before;
|
||||||
|
}, [action]);
|
||||||
|
const snapshotUrls = React.useMemo(() => {
|
||||||
|
return snapshot ? extendSnapshot(snapshot) : undefined;
|
||||||
|
}, [snapshot]);
|
||||||
|
|
||||||
|
return <SnapshotView
|
||||||
|
sdkLanguage='javascript'
|
||||||
|
testIdAttributeName='data-testid'
|
||||||
|
isInspecting={isInspecting}
|
||||||
|
setIsInspecting={setIsInspecting}
|
||||||
|
highlightedLocator={highlightedLocator}
|
||||||
|
setHighlightedLocator={setHighlightedLocator}
|
||||||
|
snapshotUrls={snapshotUrls} />;
|
||||||
|
};
|
||||||
|
|
@ -1,407 +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 type { Language } from '@isomorphic/locatorGenerators';
|
|
||||||
import type { Mode, Source } from '@recorder/recorderTypes';
|
|
||||||
import { SplitView } from '@web/components/splitView';
|
|
||||||
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
|
|
||||||
import { TabbedPane } from '@web/components/tabbedPane';
|
|
||||||
import { sha1, useSetting } from '@web/uiUtils';
|
|
||||||
import * as React from 'react';
|
|
||||||
import type { ContextEntry } from '../types/entries';
|
|
||||||
import type { Boundaries } from './geometry';
|
|
||||||
import { ActionList } from './actionList';
|
|
||||||
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
|
|
||||||
import { InspectorTab } from './inspectorTab';
|
|
||||||
import type * as modelUtil from './modelUtil';
|
|
||||||
import type { SourceLocation } from './modelUtil';
|
|
||||||
import { MultiTraceModel } from './modelUtil';
|
|
||||||
import { NetworkTab, useNetworkTabModel } from './networkTab';
|
|
||||||
import './recorderView.css';
|
|
||||||
import { collectSnapshots, extendSnapshot, SnapshotView } from './snapshotTab';
|
|
||||||
import { SourceTab } from './sourceTab';
|
|
||||||
import { Toolbar } from '@web/components/toolbar';
|
|
||||||
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
|
|
||||||
import { toggleTheme } from '@web/theme';
|
|
||||||
import { SourceChooser } from '@web/components/sourceChooser';
|
|
||||||
import type * as actions from '@recorder/actions';
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
|
||||||
const guid = searchParams.get('ws');
|
|
||||||
const traceLocation = searchParams.get('trace') + '.json';
|
|
||||||
|
|
||||||
export const RecorderView: React.FunctionComponent = () => {
|
|
||||||
const [connection, setConnection] = React.useState<Connection | null>(null);
|
|
||||||
const [sources, setSources] = React.useState<Source[]>([]);
|
|
||||||
const [, setActions] = React.useState<actions.ActionInContext[]>([]);
|
|
||||||
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>();
|
|
||||||
const [mode, setMode] = React.useState<Mode>('none');
|
|
||||||
const [counter, setCounter] = React.useState(0);
|
|
||||||
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const wsURL = new URL(`../${guid}`, window.location.toString());
|
|
||||||
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
|
||||||
const webSocket = new WebSocket(wsURL.toString());
|
|
||||||
setConnection(new Connection(webSocket, { setMode, setSources, setActions }));
|
|
||||||
return () => {
|
|
||||||
webSocket.close();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (pollTimer.current)
|
|
||||||
clearTimeout(pollTimer.current);
|
|
||||||
|
|
||||||
// Start polling running test.
|
|
||||||
pollTimer.current = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const result = await loadSingleTraceFile(traceLocation);
|
|
||||||
if (result.sha1 !== model?.sha1)
|
|
||||||
setModel({ ...result, isLive: true });
|
|
||||||
} catch {
|
|
||||||
setModel(undefined);
|
|
||||||
} finally {
|
|
||||||
setCounter(counter + 1);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
return () => {
|
|
||||||
if (pollTimer.current)
|
|
||||||
clearTimeout(pollTimer.current);
|
|
||||||
};
|
|
||||||
}, [counter, model]);
|
|
||||||
|
|
||||||
return <div className='vbox workbench-loader'>
|
|
||||||
<Workbench
|
|
||||||
key='workbench'
|
|
||||||
mode={mode}
|
|
||||||
setMode={mode => connection?.setMode(mode)}
|
|
||||||
model={model?.model}
|
|
||||||
sources={sources}
|
|
||||||
/>
|
|
||||||
</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadSingleTraceFile(url: string): Promise<{ model: MultiTraceModel, sha1: string }> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('trace', url);
|
|
||||||
const response = await fetch(`contexts?${params.toString()}`);
|
|
||||||
const contextEntries = await response.json() as ContextEntry[];
|
|
||||||
|
|
||||||
const tokens: string[] = [];
|
|
||||||
for (const entry of contextEntries) {
|
|
||||||
entry.actions.forEach(a => tokens.push(a.type + '@' + a.startTime + '-' + a.endTime));
|
|
||||||
entry.events.forEach(e => tokens.push(e.type + '@' + e.time));
|
|
||||||
}
|
|
||||||
return { model: new MultiTraceModel(contextEntries), sha1: await sha1(tokens.join('|')) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Workbench: React.FunctionComponent<{
|
|
||||||
mode: Mode,
|
|
||||||
setMode: (mode: Mode) => void,
|
|
||||||
model?: modelUtil.MultiTraceModel,
|
|
||||||
sources: Source[],
|
|
||||||
}> = ({ mode, setMode, model, sources }) => {
|
|
||||||
const [fileId, setFileId] = React.useState<string | undefined>();
|
|
||||||
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
|
|
||||||
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('recorderPropertiesTab', 'source');
|
|
||||||
const [isInspecting, setIsInspectingState] = React.useState(false);
|
|
||||||
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
|
|
||||||
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
|
|
||||||
const sourceModel = React.useRef(new Map<string, modelUtil.SourceModel>());
|
|
||||||
|
|
||||||
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
|
|
||||||
setSelectedCallId(action?.callId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedAction = React.useMemo(() => {
|
|
||||||
return model?.actions.find(a => a.callId === selectedCallId);
|
|
||||||
}, [model, selectedCallId]);
|
|
||||||
|
|
||||||
const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => {
|
|
||||||
setSelectedAction(action);
|
|
||||||
}, [setSelectedAction]);
|
|
||||||
|
|
||||||
const selectPropertiesTab = React.useCallback((tab: string) => {
|
|
||||||
setSelectedPropertiesTab(tab);
|
|
||||||
if (tab !== 'inspector')
|
|
||||||
setIsInspectingState(false);
|
|
||||||
}, [setSelectedPropertiesTab]);
|
|
||||||
|
|
||||||
const setIsInspecting = React.useCallback((value: boolean) => {
|
|
||||||
if (!isInspecting && value)
|
|
||||||
selectPropertiesTab('inspector');
|
|
||||||
setIsInspectingState(value);
|
|
||||||
}, [setIsInspectingState, selectPropertiesTab, isInspecting]);
|
|
||||||
|
|
||||||
const locatorPicked = React.useCallback((locator: string) => {
|
|
||||||
setHighlightedLocator(locator);
|
|
||||||
selectPropertiesTab('inspector');
|
|
||||||
}, [selectPropertiesTab]);
|
|
||||||
|
|
||||||
const consoleModel = useConsoleTabModel(model, selectedTime);
|
|
||||||
const networkModel = useNetworkTabModel(model, selectedTime);
|
|
||||||
const sdkLanguage = model?.sdkLanguage || 'javascript';
|
|
||||||
|
|
||||||
const inspectorTab: TabbedPaneTabModel = {
|
|
||||||
id: 'inspector',
|
|
||||||
title: 'Locator',
|
|
||||||
render: () => <InspectorTab
|
|
||||||
sdkLanguage={sdkLanguage}
|
|
||||||
setIsInspecting={setIsInspecting}
|
|
||||||
highlightedLocator={highlightedLocator}
|
|
||||||
setHighlightedLocator={setHighlightedLocator} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const source = React.useMemo(() => sources.find(s => s.id === fileId) || sources[0], [sources, fileId]);
|
|
||||||
|
|
||||||
const fallbackLocation = React.useMemo(() => {
|
|
||||||
if (!source)
|
|
||||||
return undefined;
|
|
||||||
const fallbackLocation: SourceLocation = {
|
|
||||||
file: '',
|
|
||||||
line: 0,
|
|
||||||
column: 0,
|
|
||||||
source: {
|
|
||||||
errors: [],
|
|
||||||
content: source.text
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return fallbackLocation;
|
|
||||||
}, [source]);
|
|
||||||
|
|
||||||
const sourceTab: TabbedPaneTabModel = {
|
|
||||||
id: 'source',
|
|
||||||
title: 'Source',
|
|
||||||
render: () => <SourceTab
|
|
||||||
sources={sourceModel.current}
|
|
||||||
stackFrameLocation={'right'}
|
|
||||||
fallbackLocation={fallbackLocation}
|
|
||||||
/>
|
|
||||||
};
|
|
||||||
const consoleTab: TabbedPaneTabModel = {
|
|
||||||
id: 'console',
|
|
||||||
title: 'Console',
|
|
||||||
count: consoleModel.entries.length,
|
|
||||||
render: () => <ConsoleTab
|
|
||||||
consoleModel={consoleModel}
|
|
||||||
boundaries={boundaries}
|
|
||||||
selectedTime={selectedTime}
|
|
||||||
onAccepted={m => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })}
|
|
||||||
/>
|
|
||||||
};
|
|
||||||
const networkTab: TabbedPaneTabModel = {
|
|
||||||
id: 'network',
|
|
||||||
title: 'Network',
|
|
||||||
count: networkModel.resources.length,
|
|
||||||
render: () => <NetworkTab boundaries={boundaries} networkModel={networkModel} />
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs: TabbedPaneTabModel[] = [
|
|
||||||
sourceTab,
|
|
||||||
inspectorTab,
|
|
||||||
consoleTab,
|
|
||||||
networkTab,
|
|
||||||
];
|
|
||||||
|
|
||||||
const { boundaries } = React.useMemo(() => {
|
|
||||||
const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 };
|
|
||||||
if (boundaries.minimum > boundaries.maximum) {
|
|
||||||
boundaries.minimum = 0;
|
|
||||||
boundaries.maximum = 30000;
|
|
||||||
}
|
|
||||||
// Leave some nice free space on the right hand side.
|
|
||||||
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
|
|
||||||
return { boundaries };
|
|
||||||
}, [model]);
|
|
||||||
|
|
||||||
const actionList = <ActionList
|
|
||||||
sdkLanguage={sdkLanguage}
|
|
||||||
actions={model?.actions || []}
|
|
||||||
selectedAction={model ? selectedAction : undefined}
|
|
||||||
selectedTime={selectedTime}
|
|
||||||
setSelectedTime={setSelectedTime}
|
|
||||||
onSelected={onActionSelected}
|
|
||||||
revealConsole={() => selectPropertiesTab('console')}
|
|
||||||
isLive={true}
|
|
||||||
/>;
|
|
||||||
|
|
||||||
const actionsTab: TabbedPaneTabModel = {
|
|
||||||
id: 'actions',
|
|
||||||
title: 'Actions',
|
|
||||||
component: actionList,
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolbar = <Toolbar sidebarBackground>
|
|
||||||
<div style={{ width: 4 }}></div>
|
|
||||||
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording'} onClick={() => {
|
|
||||||
setMode(mode === 'recording' ? 'standby' : 'recording');
|
|
||||||
}}>Record</ToolbarButton>
|
|
||||||
<ToolbarSeparator />
|
|
||||||
<ToolbarButton icon='inspect' title='Pick locator' toggled={isInspecting} onClick={() => {
|
|
||||||
setIsInspecting(!isInspecting);
|
|
||||||
}} />
|
|
||||||
<ToolbarButton icon='eye' title='Assert visibility' onClick={() => {
|
|
||||||
}} />
|
|
||||||
<ToolbarButton icon='whole-word' title='Assert text' onClick={() => {
|
|
||||||
}} />
|
|
||||||
<ToolbarButton icon='symbol-constant' title='Assert value' onClick={() => {
|
|
||||||
}} />
|
|
||||||
<ToolbarSeparator />
|
|
||||||
<ToolbarButton icon='files' title='Copy' onClick={() => {
|
|
||||||
}} />
|
|
||||||
<div style={{ flex: 'auto' }}></div>
|
|
||||||
<div>Target:</div>
|
|
||||||
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
|
|
||||||
setFileId(fileId);
|
|
||||||
}} />
|
|
||||||
<ToolbarButton icon='clear-all' title='Clear' onClick={() => {
|
|
||||||
}}></ToolbarButton>
|
|
||||||
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
|
|
||||||
</Toolbar>;
|
|
||||||
|
|
||||||
const sidebarTabbedPane = <TabbedPane tabs={[actionsTab]} />;
|
|
||||||
|
|
||||||
const propertiesTabbedPane = <TabbedPane
|
|
||||||
tabs={tabs}
|
|
||||||
selectedTab={selectedPropertiesTab}
|
|
||||||
setSelectedTab={selectPropertiesTab}
|
|
||||||
/>;
|
|
||||||
|
|
||||||
const snapshotView = <SnapshotContainer
|
|
||||||
sdkLanguage={sdkLanguage}
|
|
||||||
action={selectedAction}
|
|
||||||
testIdAttributeName={model?.testIdAttributeName || 'data-testid'}
|
|
||||||
isInspecting={isInspecting}
|
|
||||||
setIsInspecting={setIsInspecting}
|
|
||||||
highlightedLocator={highlightedLocator}
|
|
||||||
locatorPicked={locatorPicked} />;
|
|
||||||
|
|
||||||
return <div className='vbox workbench'>
|
|
||||||
<SplitView
|
|
||||||
sidebarSize={250}
|
|
||||||
orientation={'horizontal'}
|
|
||||||
settingName='recorderActionListSidebar'
|
|
||||||
sidebarIsFirst
|
|
||||||
main={<SplitView
|
|
||||||
sidebarSize={250}
|
|
||||||
orientation='vertical'
|
|
||||||
settingName='recorderPropertiesSidebar'
|
|
||||||
main={<div className='vbox'>
|
|
||||||
{toolbar}
|
|
||||||
{snapshotView}
|
|
||||||
</div>}
|
|
||||||
sidebar={propertiesTabbedPane}
|
|
||||||
/>}
|
|
||||||
sidebar={sidebarTabbedPane}
|
|
||||||
/>
|
|
||||||
</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SnapshotContainer: React.FunctionComponent<{
|
|
||||||
sdkLanguage: Language,
|
|
||||||
action: modelUtil.ActionTraceEventInContext | undefined,
|
|
||||||
testIdAttributeName?: string,
|
|
||||||
isInspecting: boolean,
|
|
||||||
highlightedLocator: string,
|
|
||||||
setIsInspecting: (value: boolean) => void,
|
|
||||||
locatorPicked: (locator: string) => void,
|
|
||||||
}> = ({ sdkLanguage, action, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, locatorPicked }) => {
|
|
||||||
const snapshot = React.useMemo(() => {
|
|
||||||
const snapshot = collectSnapshots(action);
|
|
||||||
return snapshot.action || snapshot.after || snapshot.before;
|
|
||||||
}, [action]);
|
|
||||||
const snapshotUrls = React.useMemo(() => {
|
|
||||||
return snapshot ? extendSnapshot(snapshot) : undefined;
|
|
||||||
}, [snapshot]);
|
|
||||||
return <SnapshotView
|
|
||||||
sdkLanguage={sdkLanguage}
|
|
||||||
testIdAttributeName={testIdAttributeName || 'data-testid'}
|
|
||||||
isInspecting={isInspecting}
|
|
||||||
setIsInspecting={setIsInspecting}
|
|
||||||
highlightedLocator={highlightedLocator}
|
|
||||||
setHighlightedLocator={locatorPicked}
|
|
||||||
snapshotUrls={snapshotUrls} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConnectionOptions = {
|
|
||||||
setMode: (mode: Mode) => void;
|
|
||||||
setSources: (sources: Source[]) => void;
|
|
||||||
setActions: (actions: actions.ActionInContext[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Connection {
|
|
||||||
private _lastId = 0;
|
|
||||||
private _webSocket: WebSocket;
|
|
||||||
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
|
|
||||||
private _options: ConnectionOptions;
|
|
||||||
|
|
||||||
constructor(webSocket: WebSocket, options: ConnectionOptions) {
|
|
||||||
this._webSocket = webSocket;
|
|
||||||
this._callbacks = new Map();
|
|
||||||
this._options = options;
|
|
||||||
|
|
||||||
this._webSocket.addEventListener('message', event => {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
const { id, result, error, method, params } = message;
|
|
||||||
if (id) {
|
|
||||||
const callback = this._callbacks.get(id);
|
|
||||||
if (!callback)
|
|
||||||
return;
|
|
||||||
this._callbacks.delete(id);
|
|
||||||
if (error)
|
|
||||||
callback.reject(new Error(error));
|
|
||||||
else
|
|
||||||
callback.resolve(result);
|
|
||||||
} else {
|
|
||||||
this._dispatchEvent(method, params);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setMode(mode: Mode) {
|
|
||||||
this._sendMessageNoReply('setMode', { mode });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _sendMessage(method: string, params?: any): Promise<any> {
|
|
||||||
const id = ++this._lastId;
|
|
||||||
const message = { id, method, params };
|
|
||||||
this._webSocket.send(JSON.stringify(message));
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this._callbacks.set(id, { resolve, reject });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _sendMessageNoReply(method: string, params?: any) {
|
|
||||||
this._sendMessage(method, params).catch(() => { });
|
|
||||||
}
|
|
||||||
|
|
||||||
private _dispatchEvent(method: string, params?: any) {
|
|
||||||
if (method === 'setMode') {
|
|
||||||
const { mode } = params as { mode: Mode };
|
|
||||||
this._options.setMode(mode);
|
|
||||||
}
|
|
||||||
if (method === 'setSources') {
|
|
||||||
const { sources } = params as { sources: Source[] };
|
|
||||||
this._options.setSources(sources);
|
|
||||||
window.playwrightSourcesEchoForTest = sources;
|
|
||||||
}
|
|
||||||
if (method === 'setActions') {
|
|
||||||
const { actions } = params as { actions: actions.ActionInContext[] };
|
|
||||||
this._options.setActions(actions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -160,7 +160,7 @@ export async function parseTraceRaw(file: string): Promise<{ events: any[], reso
|
||||||
export async function parseTrace(file: string): Promise<{ resources: Map<string, Buffer>, events: (EventTraceEvent | ConsoleMessageTraceEvent)[], actions: ActionTraceEvent[], apiNames: string[], traceModel: TraceModel, model: MultiTraceModel, actionTree: string[], errors: string[] }> {
|
export async function parseTrace(file: string): Promise<{ resources: Map<string, Buffer>, events: (EventTraceEvent | ConsoleMessageTraceEvent)[], actions: ActionTraceEvent[], apiNames: string[], traceModel: TraceModel, model: MultiTraceModel, actionTree: string[], errors: string[] }> {
|
||||||
const backend = new TraceBackend(file);
|
const backend = new TraceBackend(file);
|
||||||
const traceModel = new TraceModel();
|
const traceModel = new TraceModel();
|
||||||
await traceModel.load(backend, false, () => {});
|
await traceModel.load(backend, () => {});
|
||||||
const model = new MultiTraceModel(traceModel.contextEntries);
|
const model = new MultiTraceModel(traceModel.contextEntries);
|
||||||
const { rootItem } = buildActionTree(model.actions);
|
const { rootItem } = buildActionTree(model.actions);
|
||||||
const actionTree: string[] = [];
|
const actionTree: string[] = [];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue