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,
|
||||
};
|
||||
|
||||
this._collection = new RecorderCollection(codegenMode, context, this._pageAliases);
|
||||
this._collection = new RecorderCollection(this._pageAliases);
|
||||
this._collection.on('change', (actions: actions.ActionInContext[]) => {
|
||||
this._recorderSources = [];
|
||||
for (const languageGenerator of this._orderedLanguages) {
|
||||
|
|
|
|||
|
|
@ -20,30 +20,20 @@ import type { Page } from '../page';
|
|||
import type { Signal } from '../../../../recorder/src/actions';
|
||||
import type * as actions from '@recorder/actions';
|
||||
import { monotonicTime } from '../../utils/time';
|
||||
import { callMetadataForAction, collapseActions, traceEventsToAction } from './recorderUtils';
|
||||
import { callMetadataForAction, collapseActions } from './recorderUtils';
|
||||
import { serializeError } from '../errors';
|
||||
import { performAction } from './recorderRunner';
|
||||
import type { CallMetadata } from '@protocol/callMetadata';
|
||||
import { isUnderTest } from '../../utils/debug';
|
||||
import type { BrowserContext } from '../browserContext';
|
||||
|
||||
export class RecorderCollection extends EventEmitter {
|
||||
private _actions: actions.ActionInContext[] = [];
|
||||
private _enabled = false;
|
||||
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();
|
||||
this._context = context;
|
||||
this._pageAliases = pageAliases;
|
||||
|
||||
if (codegenMode === 'trace-events') {
|
||||
this._context.tracing.onMemoryEvents(events => {
|
||||
this._actions = traceEventsToAction(events);
|
||||
this._fireChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
restart() {
|
||||
|
|
@ -86,7 +76,8 @@ export class RecorderCollection extends EventEmitter {
|
|||
const error = await callback?.(callMetadata).catch((e: Error) => e);
|
||||
callMetadata.endTime = monotonicTime();
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -20,12 +20,10 @@ import type { Page } from '../page';
|
|||
import type { Frame } from '../frames';
|
||||
import type * as actions from '@recorder/actions';
|
||||
import type * as channels from '@protocol/channels';
|
||||
import type * as trace from '@trace/trace';
|
||||
import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language';
|
||||
import { toKeyboardModifiers } from '../codegen/language';
|
||||
import { serializeExpectedTextValues } from '../../utils/expectUtils';
|
||||
import { createGuid, monotonicTime } from '../../utils';
|
||||
import { parseSerializedValue, serializeValue } from '../../protocol/serializers';
|
||||
import type { SmartKeyboardModifier } from '../types';
|
||||
import { createGuid } from '../../utils';
|
||||
import { serializeValue } from '../../protocol/serializers';
|
||||
|
||||
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
|
||||
let title = metadata.apiName || metadata.method;
|
||||
|
|
@ -201,7 +199,7 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
|
|||
objectId: mainFrame.guid,
|
||||
pageId: mainFrame._page.guid,
|
||||
frameId: mainFrame.guid,
|
||||
startTime: monotonicTime(),
|
||||
startTime: actionInContext.timestamp,
|
||||
endTime: 0,
|
||||
type: 'Frame',
|
||||
method,
|
||||
|
|
@ -211,281 +209,6 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
|
|||
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[] {
|
||||
const result: actions.ActionInContext[] = [];
|
||||
for (const action of actions) {
|
||||
|
|
|
|||
|
|
@ -6,3 +6,7 @@ ui/
|
|||
|
||||
[sw-main.ts]
|
||||
sw/**
|
||||
|
||||
|
||||
[recorder.tsx]
|
||||
ui/recorder/**
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import '@web/common.css';
|
|||
import { applyTheme } from '@web/theme';
|
||||
import '@web/third_party/vscode/codicon.css';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { RecorderView } from './ui/recorderView';
|
||||
import { RecorderView } from './ui/recorder/recorderView';
|
||||
|
||||
(async () => {
|
||||
applyTheme();
|
||||
|
|
|
|||
|
|
@ -47,14 +47,12 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
|
|||
}
|
||||
set.add(traceUrl);
|
||||
|
||||
const isRecorderMode = traceUrl.includes('/playwright-recorder-trace-');
|
||||
|
||||
const traceModel = new TraceModel();
|
||||
try {
|
||||
// Allow 10% to hop from sw to page.
|
||||
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
import { parseClientSideCallMetadata } from '@isomorphic/traceUtils';
|
||||
import type { ActionEntry, ContextEntry } from '../types/entries';
|
||||
import type { ContextEntry } from '../types/entries';
|
||||
import { SnapshotStorage } from './snapshotStorage';
|
||||
import { TraceModernizer } from './traceModernizer';
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ export class TraceModel {
|
|||
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;
|
||||
|
||||
const ordinals: string[] = [];
|
||||
|
|
@ -71,8 +71,7 @@ export class TraceModel {
|
|||
modernizer.appendTrace(network);
|
||||
unzipProgress(++done, total);
|
||||
|
||||
const actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
||||
contextEntry.actions = isRecorderMode ? collapseActionsForRecorder(actions) : actions;
|
||||
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
||||
|
||||
if (!backend.isLive()) {
|
||||
// Terminate actions w/o after event gracefully.
|
||||
|
|
@ -134,22 +133,6 @@ function stripEncodingFromContentType(contentType: string) {
|
|||
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 {
|
||||
return {
|
||||
origin: 'testRunner',
|
||||
|
|
|
|||
|
|
@ -106,9 +106,9 @@ export function useConsoleTabModel(model: modelUtil.MultiTraceModel | undefined,
|
|||
export const ConsoleTab: React.FunctionComponent<{
|
||||
boundaries: Boundaries,
|
||||
consoleModel: ConsoleTabModel,
|
||||
selectedTime: Boundaries | undefined,
|
||||
selectedTime?: Boundaries | undefined,
|
||||
onEntryHovered?: (entry: ConsoleEntry | undefined) => void,
|
||||
onAccepted: (entry: ConsoleEntry) => void,
|
||||
onAccepted?: (entry: ConsoleEntry) => void,
|
||||
}> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => {
|
||||
if (!consoleModel.entries.length)
|
||||
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[] }> {
|
||||
const backend = new TraceBackend(file);
|
||||
const traceModel = new TraceModel();
|
||||
await traceModel.load(backend, false, () => {});
|
||||
await traceModel.load(backend, () => {});
|
||||
const model = new MultiTraceModel(traceModel.contextEntries);
|
||||
const { rootItem } = buildActionTree(model.actions);
|
||||
const actionTree: string[] = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue