chore: start putting tv-recorder ui together (#32776)
This commit is contained in:
parent
7c3dd70bf6
commit
8649b13f25
|
|
@ -43,6 +43,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp
|
||||||
constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) {
|
constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) {
|
||||||
super();
|
super();
|
||||||
this._transport = transport;
|
this._transport = transport;
|
||||||
|
this._transport.eventSink.resolve(this);
|
||||||
this._tracePage = tracePage;
|
this._tracePage = tracePage;
|
||||||
this._traceServer = traceServer;
|
this._traceServer = traceServer;
|
||||||
this.wsEndpointForTest = wsEndpointForTest;
|
this.wsEndpointForTest = wsEndpointForTest;
|
||||||
|
|
@ -94,6 +95,7 @@ async function openApp(trace: string, options?: TraceViewerServerOptions & { hea
|
||||||
|
|
||||||
class RecorderTransport implements Transport {
|
class RecorderTransport implements Transport {
|
||||||
private _connected = new ManualPromise<void>();
|
private _connected = new ManualPromise<void>();
|
||||||
|
readonly eventSink = new ManualPromise<EventEmitter>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +105,8 @@ class RecorderTransport implements Transport {
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatch(method: string, params: any): Promise<any> {
|
async dispatch(method: string, params: any): Promise<any> {
|
||||||
|
const eventSink = await this.eventSink;
|
||||||
|
eventSink.emit('event', { event: method, params });
|
||||||
}
|
}
|
||||||
|
|
||||||
onclose() {
|
onclose() {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
||||||
import { SplitView } from '@web/components/splitView';
|
import { SplitView } from '@web/components/splitView';
|
||||||
import { TabbedPane } from '@web/components/tabbedPane';
|
import { TabbedPane } from '@web/components/tabbedPane';
|
||||||
import { Toolbar } from '@web/components/toolbar';
|
import { Toolbar } from '@web/components/toolbar';
|
||||||
|
import { emptySource, SourceChooser } from '@web/components/sourceChooser';
|
||||||
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
|
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CallLogView } from './callLog';
|
import { CallLogView } from './callLog';
|
||||||
|
|
@ -54,15 +55,7 @@ export const Recorder: React.FC<RecorderProps> = ({
|
||||||
if (source)
|
if (source)
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
const source: Source = {
|
return emptySource();
|
||||||
id: 'default',
|
|
||||||
isRecorded: false,
|
|
||||||
text: '',
|
|
||||||
language: 'javascript',
|
|
||||||
label: '',
|
|
||||||
highlight: []
|
|
||||||
};
|
|
||||||
return source;
|
|
||||||
}, [sources, fileId]);
|
}, [sources, fileId]);
|
||||||
|
|
||||||
const [locator, setLocator] = React.useState('');
|
const [locator, setLocator] = React.useState('');
|
||||||
|
|
@ -152,10 +145,10 @@ export const Recorder: React.FC<RecorderProps> = ({
|
||||||
}}></ToolbarButton>
|
}}></ToolbarButton>
|
||||||
<div style={{ flex: 'auto' }}></div>
|
<div style={{ flex: 'auto' }}></div>
|
||||||
<div>Target:</div>
|
<div>Target:</div>
|
||||||
<select className='recorder-chooser' hidden={!sources.length} value={fileId} onChange={event => {
|
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
|
||||||
setFileId(event.target.selectedOptions[0].value);
|
setFileId(fileId);
|
||||||
window.dispatch({ event: 'fileChanged', params: { file: event.target.selectedOptions[0].value } });
|
window.dispatch({ event: 'fileChanged', params: { file: fileId } });
|
||||||
}}>{renderSourceOptions(sources)}</select>
|
}} />
|
||||||
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
|
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
|
||||||
window.dispatch({ event: 'clear' });
|
window.dispatch({ event: 'clear' });
|
||||||
}}></ToolbarButton>
|
}}></ToolbarButton>
|
||||||
|
|
@ -184,22 +177,3 @@ export const Recorder: React.FC<RecorderProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderSourceOptions(sources: Source[]): React.ReactNode {
|
|
||||||
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
|
|
||||||
const renderOption = (source: Source): React.ReactNode => (
|
|
||||||
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasGroup = sources.some(s => s.group);
|
|
||||||
if (hasGroup) {
|
|
||||||
const groups = new Set(sources.map(s => s.group));
|
|
||||||
return [...groups].filter(Boolean).map(group => (
|
|
||||||
<optgroup label={group} key={group}>
|
|
||||||
{sources.filter(s => s.group === group).map(source => renderOption(source))}
|
|
||||||
</optgroup>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sources.map(source => renderOption(source));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ export interface ActionListProps {
|
||||||
selectedTime: Boundaries | undefined,
|
selectedTime: Boundaries | undefined,
|
||||||
setSelectedTime: (time: Boundaries | undefined) => void,
|
setSelectedTime: (time: Boundaries | undefined) => void,
|
||||||
sdkLanguage: Language | undefined;
|
sdkLanguage: Language | undefined;
|
||||||
onSelected: (action: ActionTraceEventInContext) => void,
|
onSelected?: (action: ActionTraceEventInContext) => void,
|
||||||
onHighlighted: (action: ActionTraceEventInContext | undefined) => void,
|
onHighlighted?: (action: ActionTraceEventInContext | undefined) => void,
|
||||||
revealConsole: () => void,
|
revealConsole?: () => void,
|
||||||
isLive?: boolean,
|
isLive?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,8 +67,8 @@ export const ActionList: React.FC<ActionListProps> = ({
|
||||||
treeState={treeState}
|
treeState={treeState}
|
||||||
setTreeState={setTreeState}
|
setTreeState={setTreeState}
|
||||||
selectedItem={selectedItem}
|
selectedItem={selectedItem}
|
||||||
onSelected={item => onSelected(item.action!)}
|
onSelected={item => onSelected?.(item.action!)}
|
||||||
onHighlighted={item => onHighlighted(item?.action)}
|
onHighlighted={item => onHighlighted?.(item?.action)}
|
||||||
onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })}
|
onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })}
|
||||||
isError={item => !!item.action?.error?.message}
|
isError={item => !!item.action?.error?.message}
|
||||||
isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)}
|
isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT
|
||||||
export const NetworkTab: React.FunctionComponent<{
|
export const NetworkTab: React.FunctionComponent<{
|
||||||
boundaries: Boundaries,
|
boundaries: Boundaries,
|
||||||
networkModel: NetworkTabModel,
|
networkModel: NetworkTabModel,
|
||||||
onEntryHovered: (entry: Entry | undefined) => void,
|
onEntryHovered?: (entry: Entry | undefined) => void,
|
||||||
}> = ({ boundaries, networkModel, onEntryHovered }) => {
|
}> = ({ boundaries, networkModel, onEntryHovered }) => {
|
||||||
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
|
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
|
||||||
const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined);
|
const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined);
|
||||||
|
|
@ -95,7 +95,7 @@ export const NetworkTab: React.FunctionComponent<{
|
||||||
items={renderedEntries}
|
items={renderedEntries}
|
||||||
selectedItem={selectedEntry}
|
selectedItem={selectedEntry}
|
||||||
onSelected={item => setSelectedEntry(item)}
|
onSelected={item => setSelectedEntry(item)}
|
||||||
onHighlighted={item => onEntryHovered(item?.resource)}
|
onHighlighted={item => onEntryHovered?.(item?.resource)}
|
||||||
columns={visibleColumns(!!selectedEntry, renderedEntries)}
|
columns={visibleColumns(!!selectedEntry, renderedEntries)}
|
||||||
columnTitle={columnTitle}
|
columnTitle={columnTitle}
|
||||||
columnWidths={columnWidths}
|
columnWidths={columnWidths}
|
||||||
|
|
|
||||||
|
|
@ -14,52 +14,52 @@
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react';
|
import type { Language } from '@isomorphic/locatorGenerators';
|
||||||
import './recorderView.css';
|
|
||||||
import { MultiTraceModel } from './modelUtil';
|
|
||||||
import type { SourceLocation } from './modelUtil';
|
|
||||||
import { Workbench } from './workbench';
|
|
||||||
import type { Mode, Source } from '@recorder/recorderTypes';
|
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 '../entries';
|
import type { ContextEntry } from '../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';
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const guid = searchParams.get('ws');
|
const guid = searchParams.get('ws');
|
||||||
const trace = searchParams.get('trace') + '.json';
|
const traceLocation = searchParams.get('trace') + '.json';
|
||||||
|
|
||||||
export const RecorderView: React.FunctionComponent = () => {
|
export const RecorderView: React.FunctionComponent = () => {
|
||||||
const [connection, setConnection] = React.useState<Connection | null>(null);
|
const [connection, setConnection] = React.useState<Connection | null>(null);
|
||||||
const [sources, setSources] = React.useState<Source[]>([]);
|
const [sources, setSources] = React.useState<Source[]>([]);
|
||||||
|
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(() => {
|
React.useEffect(() => {
|
||||||
const wsURL = new URL(`../${guid}`, window.location.toString());
|
const wsURL = new URL(`../${guid}`, window.location.toString());
|
||||||
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
||||||
const webSocket = new WebSocket(wsURL.toString());
|
const webSocket = new WebSocket(wsURL.toString());
|
||||||
setConnection(new Connection(webSocket, { setSources }));
|
setConnection(new Connection(webSocket, { setMode, setSources }));
|
||||||
return () => {
|
return () => {
|
||||||
webSocket.close();
|
webSocket.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!connection)
|
|
||||||
return;
|
|
||||||
connection.setMode('recording');
|
|
||||||
}, [connection]);
|
|
||||||
|
|
||||||
return <div className='vbox workbench-loader'>
|
|
||||||
<TraceView
|
|
||||||
traceLocation={trace}
|
|
||||||
sources={sources} />
|
|
||||||
</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TraceView: React.FC<{
|
|
||||||
traceLocation: string,
|
|
||||||
sources: Source[],
|
|
||||||
}> = ({ traceLocation, sources }) => {
|
|
||||||
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
|
|
||||||
const [counter, setCounter] = React.useState(0);
|
|
||||||
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (pollTimer.current)
|
if (pollTimer.current)
|
||||||
clearTimeout(pollTimer.current);
|
clearTimeout(pollTimer.current);
|
||||||
|
|
@ -67,8 +67,9 @@ export const TraceView: React.FC<{
|
||||||
// Start polling running test.
|
// Start polling running test.
|
||||||
pollTimer.current = setTimeout(async () => {
|
pollTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const model = await loadSingleTraceFile(traceLocation);
|
const result = await loadSingleTraceFile(traceLocation);
|
||||||
setModel({ model, isLive: true });
|
if (result.sha1 !== model?.sha1)
|
||||||
|
setModel({ ...result, isLive: true });
|
||||||
} catch {
|
} catch {
|
||||||
setModel(undefined);
|
setModel(undefined);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -79,10 +80,94 @@ export const TraceView: React.FC<{
|
||||||
if (pollTimer.current)
|
if (pollTimer.current)
|
||||||
clearTimeout(pollTimer.current);
|
clearTimeout(pollTimer.current);
|
||||||
};
|
};
|
||||||
}, [counter, traceLocation]);
|
}, [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(() => {
|
const fallbackLocation = React.useMemo(() => {
|
||||||
if (!sources.length)
|
if (!source)
|
||||||
return undefined;
|
return undefined;
|
||||||
const fallbackLocation: SourceLocation = {
|
const fallbackLocation: SourceLocation = {
|
||||||
file: '',
|
file: '',
|
||||||
|
|
@ -90,37 +175,178 @@ export const TraceView: React.FC<{
|
||||||
column: 0,
|
column: 0,
|
||||||
source: {
|
source: {
|
||||||
errors: [],
|
errors: [],
|
||||||
content: sources[0].text
|
content: source.text
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return fallbackLocation;
|
return fallbackLocation;
|
||||||
}, [sources]);
|
}, [source]);
|
||||||
|
|
||||||
return <Workbench
|
const sourceTab: TabbedPaneTabModel = {
|
||||||
key='workbench'
|
id: 'source',
|
||||||
model={model?.model}
|
title: 'Source',
|
||||||
showSourcesFirst={true}
|
render: () => <SourceTab
|
||||||
|
sources={sourceModel.current}
|
||||||
|
stackFrameLocation={'right'}
|
||||||
fallbackLocation={fallbackLocation}
|
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}
|
isLive={true}
|
||||||
hideTimeline={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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
|
const SnapshotContainer: React.FunctionComponent<{
|
||||||
const params = new URLSearchParams();
|
sdkLanguage: Language,
|
||||||
params.set('trace', url);
|
action: modelUtil.ActionTraceEventInContext | undefined,
|
||||||
const response = await fetch(`contexts?${params.toString()}`);
|
testIdAttributeName?: string,
|
||||||
const contextEntries = await response.json() as ContextEntry[];
|
isInspecting: boolean,
|
||||||
return new MultiTraceModel(contextEntries);
|
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 = {
|
||||||
|
setSources: (sources: Source[]) => void;
|
||||||
|
setMode: (mode: Mode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
class Connection {
|
class Connection {
|
||||||
private _lastId = 0;
|
private _lastId = 0;
|
||||||
private _webSocket: WebSocket;
|
private _webSocket: WebSocket;
|
||||||
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
|
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
|
||||||
private _options: { setSources: (sources: Source[]) => void; };
|
private _options: ConnectionOptions;
|
||||||
|
|
||||||
constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) {
|
constructor(webSocket: WebSocket, options: ConnectionOptions) {
|
||||||
this._webSocket = webSocket;
|
this._webSocket = webSocket;
|
||||||
this._callbacks = new Map();
|
this._callbacks = new Map();
|
||||||
this._options = options;
|
this._options = options;
|
||||||
|
|
@ -166,5 +392,9 @@ class Connection {
|
||||||
this._options.setSources(sources);
|
this._options.setSources(sources);
|
||||||
window.playwrightSourcesEchoForTest = sources;
|
window.playwrightSourcesEchoForTest = sources;
|
||||||
}
|
}
|
||||||
|
if (method === 'setMode') {
|
||||||
|
const { mode } = params as { mode: Mode };
|
||||||
|
this._options.setMode(mode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { ToolbarButton } from '@web/components/toolbarButton';
|
||||||
import { Toolbar } from '@web/components/toolbar';
|
import { Toolbar } from '@web/components/toolbar';
|
||||||
|
|
||||||
export const SourceTab: React.FunctionComponent<{
|
export const SourceTab: React.FunctionComponent<{
|
||||||
stack: StackFrame[] | undefined,
|
stack?: StackFrame[],
|
||||||
stackFrameLocation: 'bottom' | 'right',
|
stackFrameLocation: 'bottom' | 'right',
|
||||||
sources: Map<string, SourceModel>,
|
sources: Map<string, SourceModel>,
|
||||||
rootDir?: string,
|
rootDir?: string,
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,7 @@ export const Workbench: React.FunctionComponent<{
|
||||||
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
|
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
|
||||||
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
|
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
|
||||||
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
|
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
|
||||||
|
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
|
||||||
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
|
|
||||||
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
|
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
|
||||||
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
|
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
|
||||||
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
|
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
|
||||||
|
|
@ -77,6 +76,14 @@ export const Workbench: React.FunctionComponent<{
|
||||||
setRevealedError(undefined);
|
setRevealedError(undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const highlightedAction = React.useMemo(() => {
|
||||||
|
return model?.actions.find(a => a.callId === highlightedCallId);
|
||||||
|
}, [model, highlightedCallId]);
|
||||||
|
|
||||||
|
const setHighlightedAction = React.useCallback((highlightedAction: modelUtil.ActionTraceEventInContext | undefined) => {
|
||||||
|
setHighlightedCallId(highlightedAction?.callId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]);
|
const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
||||||
20
packages/web/src/components/sourceChooser.css
Normal file
20
packages/web/src/components/sourceChooser.css
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.source-chooser {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--vscode-sideBarTitle-foreground);
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
58
packages/web/src/components/sourceChooser.tsx
Normal file
58
packages/web/src/components/sourceChooser.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { Source } from '@recorder/recorderTypes';
|
||||||
|
|
||||||
|
export const SourceChooser: React.FC<{
|
||||||
|
sources: Source[],
|
||||||
|
fileId: string | undefined,
|
||||||
|
setFileId: (fileId: string) => void,
|
||||||
|
}> = ({ sources, fileId, setFileId }) => {
|
||||||
|
return <select className='source-chooser' hidden={!sources.length} value={fileId} onChange={event => {
|
||||||
|
setFileId(event.target.selectedOptions[0].value);
|
||||||
|
}}>{renderSourceOptions(sources)}</select>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderSourceOptions(sources: Source[]): React.ReactNode {
|
||||||
|
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
|
||||||
|
const renderOption = (source: Source): React.ReactNode => (
|
||||||
|
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasGroup = sources.some(s => s.group);
|
||||||
|
if (hasGroup) {
|
||||||
|
const groups = new Set(sources.map(s => s.group));
|
||||||
|
return [...groups].filter(Boolean).map(group => (
|
||||||
|
<optgroup label={group} key={group}>
|
||||||
|
{sources.filter(s => s.group === group).map(source => renderOption(source))}
|
||||||
|
</optgroup>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources.map(source => renderOption(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptySource(): Source {
|
||||||
|
return {
|
||||||
|
id: 'default',
|
||||||
|
isRecorded: false,
|
||||||
|
text: '',
|
||||||
|
language: 'javascript',
|
||||||
|
label: '',
|
||||||
|
highlight: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -32,11 +32,13 @@ export const TabbedPane: React.FunctionComponent<{
|
||||||
tabs: TabbedPaneTabModel[],
|
tabs: TabbedPaneTabModel[],
|
||||||
leftToolbar?: React.ReactElement[],
|
leftToolbar?: React.ReactElement[],
|
||||||
rightToolbar?: React.ReactElement[],
|
rightToolbar?: React.ReactElement[],
|
||||||
selectedTab: string,
|
selectedTab?: string,
|
||||||
setSelectedTab: (tab: string) => void,
|
setSelectedTab?: (tab: string) => void,
|
||||||
dataTestId?: string,
|
dataTestId?: string,
|
||||||
mode?: 'default' | 'select',
|
mode?: 'default' | 'select',
|
||||||
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => {
|
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => {
|
||||||
|
if (!selectedTab)
|
||||||
|
selectedTab = tabs[0].id;
|
||||||
if (!mode)
|
if (!mode)
|
||||||
mode = 'default';
|
mode = 'default';
|
||||||
return <div className='tabbed-pane' data-testid={dataTestId}>
|
return <div className='tabbed-pane' data-testid={dataTestId}>
|
||||||
|
|
@ -60,7 +62,7 @@ export const TabbedPane: React.FunctionComponent<{
|
||||||
</div>}
|
</div>}
|
||||||
{mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}>
|
{mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
<select style={{ width: '100%', background: 'none', cursor: 'pointer' }} onChange={e => {
|
<select style={{ width: '100%', background: 'none', cursor: 'pointer' }} onChange={e => {
|
||||||
setSelectedTab(tabs[e.currentTarget.selectedIndex].id);
|
setSelectedTab?.(tabs[e.currentTarget.selectedIndex].id);
|
||||||
}}>
|
}}>
|
||||||
{tabs.map(tab => {
|
{tabs.map(tab => {
|
||||||
let suffix = '';
|
let suffix = '';
|
||||||
|
|
@ -95,10 +97,10 @@ export const TabbedPaneTab: React.FunctionComponent<{
|
||||||
count?: number,
|
count?: number,
|
||||||
errorCount?: number,
|
errorCount?: number,
|
||||||
selected?: boolean,
|
selected?: boolean,
|
||||||
onSelect: (id: string) => void
|
onSelect?: (id: string) => void
|
||||||
}> = ({ id, title, count, errorCount, selected, onSelect }) => {
|
}> = ({ id, title, count, errorCount, selected, onSelect }) => {
|
||||||
return <div className={clsx('tabbed-pane-tab', selected && 'selected')}
|
return <div className={clsx('tabbed-pane-tab', selected && 'selected')}
|
||||||
onClick={() => onSelect(id)}
|
onClick={() => onSelect?.(id)}
|
||||||
title={title}
|
title={title}
|
||||||
key={id}>
|
key={id}>
|
||||||
<div className='tabbed-pane-tab-label'>{title}</div>
|
<div className='tabbed-pane-tab-label'>{title}</div>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar.toolbar-sidebar-background {
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar:after {
|
.toolbar:after {
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import * as React from 'react';
|
||||||
type ToolbarProps = {
|
type ToolbarProps = {
|
||||||
noShadow?: boolean;
|
noShadow?: boolean;
|
||||||
noMinHeight?: boolean;
|
noMinHeight?: boolean;
|
||||||
|
sidebarBackground?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: (e: React.MouseEvent) => void;
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
@ -30,7 +31,8 @@ export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
|
||||||
children,
|
children,
|
||||||
noMinHeight,
|
noMinHeight,
|
||||||
className,
|
className,
|
||||||
|
sidebarBackground,
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className)} onClick={onClick}>{children}</div>;
|
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className, sidebarBackground && 'toolbar-sidebar-background')} onClick={onClick}>{children}</div>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -203,5 +203,10 @@ export function clsx(...classes: (string | undefined | false)[]) {
|
||||||
return classes.filter(Boolean).join(' ');
|
return classes.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sha1(str: string): Promise<string> {
|
||||||
|
const buffer = new TextEncoder().encode(str);
|
||||||
|
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', buffer))).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
|
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
|
||||||
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
|
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue