cherry-pick(#20937): chore: minor trace viewer UI tweaks

This commit is contained in:
Pavel Feldman 2023-02-16 07:59:21 -08:00 committed by Pavel
parent 24be5c2881
commit 8d3481ea22
18 changed files with 406 additions and 316 deletions

View file

@ -15,11 +15,10 @@
*/
import '@web/third_party/vscode/codicon.css';
import { Workbench } from './ui/workbench';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { applyTheme } from '@web/theme';
import '@web/common.css';
import { WorkbenchLoader } from './ui/workbench';
(async () => {
applyTheme();
@ -37,5 +36,5 @@ import '@web/common.css';
setInterval(function() { fetch('ping'); }, 10000);
}
ReactDOM.render(<Workbench/>, document.querySelector('#root'));
ReactDOM.render(<WorkbenchLoader></WorkbenchLoader>, document.querySelector('#root'));
})();

View file

@ -14,50 +14,6 @@
limitations under the License.
*/
.action-list {
background-color: var(--vscode-sideBar-background);
border-top: 1px solid var(--vscode-panel-border);
}
.action-list-content {
display: flex;
flex-direction: column;
flex: auto;
position: relative;
user-select: none;
overflow: auto;
outline: none;
}
.action-entry {
display: flex;
flex: none;
cursor: pointer;
align-items: center;
white-space: nowrap;
line-height: 28px;
padding-left: 5px;
}
.action-entry.highlighted,
.action-entry.selected {
background-color: var(--vscode-list-inactiveSelectionBackground);
}
.action-entry.highlighted {
background-color: var(--vscode-list-inactiveSelectionBackground);
}
.action-list-content:focus .action-entry.selected {
background-color: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
outline: 1px solid var(--vscode-focusBorder);
}
.action-list-content:focus .action-entry.selected * {
color: var(--vscode-list-activeSelectionForeground);
}
.action-title {
flex: auto;
display: block;
@ -124,10 +80,3 @@
.action-entry .codicon-warning {
color: darkorange;
}
.no-actions-entry {
flex: auto;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -16,6 +16,7 @@
import type { ActionTraceEvent } from '@trace/trace';
import { msToString } from '@web/uiUtils';
import { ListView } from '@web/components/listView';
import * as React from 'react';
import './actionList.css';
import * as modelUtil from './modelUtil';
@ -26,7 +27,6 @@ import type { Language } from '@isomorphic/locatorGenerators';
export interface ActionListProps {
actions: ActionTraceEvent[],
selectedAction: ActionTraceEvent | undefined,
highlightedAction: ActionTraceEvent | undefined,
sdkLanguage: Language | undefined;
onSelected: (action: ActionTraceEvent) => void,
onHighlighted: (action: ActionTraceEvent | undefined) => void,
@ -36,92 +36,33 @@ export interface ActionListProps {
export const ActionList: React.FC<ActionListProps> = ({
actions = [],
selectedAction,
highlightedAction,
sdkLanguage,
onSelected = () => {},
onHighlighted = () => {},
setSelectedTab = () => {},
}) => {
const actionListRef = React.createRef<HTMLDivElement>();
React.useEffect(() => {
actionListRef.current?.focus();
}, [selectedAction, actionListRef]);
return <div className='action-list vbox'>
<div
className='action-list-content'
tabIndex={0}
onKeyDown={event => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
return;
event.stopPropagation();
event.preventDefault();
const index = selectedAction ? actions.indexOf(selectedAction) : -1;
let newIndex = index;
if (event.key === 'ArrowDown') {
if (index === -1)
newIndex = 0;
else
newIndex = Math.min(index + 1, actions.length - 1);
}
if (event.key === 'ArrowUp') {
if (index === -1)
newIndex = actions.length - 1;
else
newIndex = Math.max(index - 1, 0);
}
const element = actionListRef.current?.children.item(newIndex);
scrollIntoViewIfNeeded(element);
onSelected(actions[newIndex]);
}}
ref={actionListRef}
>
{actions.length === 0 && <div className='no-actions-entry'>No actions recorded</div>}
{actions.map(action => <ActionListItem
action={action}
highlightedAction={highlightedAction}
onSelected={onSelected}
onHighlighted={onHighlighted}
selectedAction={selectedAction}
sdkLanguage={sdkLanguage}
setSelectedTab={setSelectedTab}
/>)}
</div>
</div>;
return <ListView
items={actions}
selectedItem={selectedAction}
onSelected={(action: ActionTraceEvent) => onSelected(action)}
onHighlighted={(action: ActionTraceEvent) => onHighlighted(action)}
itemKey={(action: ActionTraceEvent) => action.metadata.id}
itemRender={(action: ActionTraceEvent) => renderAction(action, sdkLanguage, setSelectedTab)}
showNoItemsMessage={true}
></ListView>;
};
const ActionListItem: React.FC<{
const renderAction = (
action: ActionTraceEvent,
highlightedAction: ActionTraceEvent | undefined,
onSelected: (action: ActionTraceEvent) => void,
onHighlighted: (action: ActionTraceEvent | undefined) => void,
selectedAction: ActionTraceEvent | undefined,
sdkLanguage: Language | undefined,
setSelectedTab: (tab: string) => void,
}> = ({ action, onSelected, onHighlighted, highlightedAction, selectedAction, sdkLanguage, setSelectedTab }) => {
setSelectedTab: (tab: string) => void
) => {
const { metadata } = action;
const selectedSuffix = action === selectedAction ? ' selected' : '';
const highlightedSuffix = action === highlightedAction ? ' highlighted' : '';
const error = metadata.error?.error?.message;
const { errors, warnings } = modelUtil.stats(action);
const locator = metadata.params.selector ? asLocator(sdkLanguage || 'javascript', metadata.params.selector) : undefined;
const divRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (divRef.current && selectedAction === action)
scrollIntoViewIfNeeded(divRef.current);
}, [selectedAction, action]);
return <div
className={'action-entry' + selectedSuffix + highlightedSuffix}
key={metadata.id}
onClick={() => onSelected(action)}
onMouseEnter={() => onHighlighted(action)}
onMouseLeave={() => (highlightedAction === action) && onHighlighted(undefined)}
ref={divRef}
>
return <>
<div className='action-title'>
<span>{metadata.apiName}</span>
{locator && <div className='action-selector' title={locator}>{locator}</div>}
@ -133,14 +74,5 @@ const ActionListItem: React.FC<{
{!!warnings && <div className='action-icon'><span className={'codicon codicon-warning'}></span><span className="action-icon-value">{warnings}</span></div>}
</div>
{error && <div className='codicon codicon-issues' title={error} />}
</div>;
</>;
};
function scrollIntoViewIfNeeded(element?: Element | null) {
if (!element)
return;
if ((element as any)?.scrollIntoViewIfNeeded)
(element as any).scrollIntoViewIfNeeded(false);
else
element?.scrollIntoView();
}

View file

@ -41,10 +41,8 @@
font-weight: bold;
text-transform: uppercase;
font-size: 10px;
border-bottom: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-sideBarTitle-foreground);
line-height: 18px;
line-height: 24px;
}
.call-line {
@ -68,6 +66,7 @@
}
.call-value {
margin-left: 2px;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
@ -77,18 +76,18 @@
content: '\00a0';
}
.call-line .datetime,
.call-line .string,
.call-line .locator {
.call-value.datetime,
.call-value.string,
.call-value.locator {
color: var(--orange);
}
.call-line .number,
.call-line .bigint,
.call-line .boolean,
.call-line .symbol,
.call-line .undefined,
.call-line .function,
.call-line .object {
.call-value.number,
.call-value.bigint,
.call-value.boolean,
.call-value.symbol,
.call-value.undefined,
.call-value.function,
.call-value.object {
color: var(--blue);
}

View file

@ -46,8 +46,8 @@ export const CallTab: React.FunctionComponent<{
<div className='call-line'>{action.metadata.apiName}</div>
{<>
<div className='call-section'>Time</div>
{action.metadata.wallTime && <div className='call-line'>wall time: <span className='call-value datetime' title={wallTime}>{wallTime}</span></div>}
<div className='call-line'>duration: <span className='call-value datetime' title={duration}>{duration}</span></div>
{action.metadata.wallTime && <div className='call-line'>wall time:<span className='call-value datetime' title={wallTime}>{wallTime}</span></div>}
<div className='call-line'>duration:<span className='call-value datetime' title={duration}>{duration}</span></div>
</>}
{ !!paramKeys.length && <div className='call-section'>Parameters</div> }
{
@ -82,7 +82,7 @@ function renderProperty(property: Property, key: string) {
text = `"${text}"`;
return (
<div key={key} className='call-line'>
{property.name}: <span className={`call-value ${property.type}`} title={property.text}>{text}</span>
{property.name}:<span className={`call-value ${property.type}`} title={property.text}>{text}</span>
{ ['string', 'number', 'object', 'locator'].includes(property.type) &&
<CopyToClipboard value={property.text} />
}

View file

@ -1,23 +0,0 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.codicon-check {
color: var(--green);
}
.codicon-close {
color: var(--red);
}

View file

@ -15,7 +15,6 @@
*/
import * as React from 'react';
import './copyToClipboard.css';
export const CopyToClipboard: React.FunctionComponent<{
value: string,

View file

@ -15,11 +15,10 @@
*/
.network-request {
white-space: nowrap;
display: flex;
white-space: nowrap;
align-items: center;
padding: 0 3px;
width: 100%;
flex: none;
outline: none;
}
@ -58,6 +57,7 @@
.network-request-details {
width: 100%;
user-select: text;
line-height: 24px;
}
.network-request-details-url {
@ -84,6 +84,7 @@
background-color: var(--vscode-sideBar-background);
border: black 1px solid;
max-height: 500px;
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.network-request-details-header {

View file

@ -21,6 +21,7 @@
align-items: stretch;
outline: none;
--window-header-height: 40px;
overflow: hidden;
}
.snapshot-controls {
@ -85,18 +86,25 @@ iframe#snapshot {
.popout-icon {
position: absolute;
top: 10px;
right: 10px;
color: var(--gray);
top: 0;
right: 0;
color: var(--vscode-sideBarTitle-foreground);
font-size: 14px;
z-index: 100;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.popout-icon:not(.popout-disabled):hover {
color: var(--blue);
color: var(--vscode-foreground);
}
.popout-icon.popout-disabled {
opacity: 0.7;
opacity: var(--vscode-disabledForeground);
}
.window-dot {
@ -109,11 +117,11 @@ iframe#snapshot {
}
.window-address-bar {
background-color: white;
background-color: var(--vscode-input-background);
border-radius: 12.5px;
color: #1c1e21;
color: var(--vscode-input-foreground);
flex: 1 0;
font: 400 13px Arial,sans-serif;
font: 400 16px Arial,sans-serif;
margin: 0 16px 0 8px;
padding: 5px 15px;
overflow: hidden;
@ -121,11 +129,6 @@ iframe#snapshot {
white-space: nowrap;
}
body.dark-mode .window-address-bar {
background-color: #1b1b1d;
color: #e3e3e3;
}
.window-menu-bar {
background-color: #aaa;
display: block;

View file

@ -41,10 +41,8 @@ export const Timeline: React.FunctionComponent<{
context: MultiTraceModel,
boundaries: Boundaries,
selectedAction: ActionTraceEvent | undefined,
highlightedAction: ActionTraceEvent | undefined,
onSelected: (action: ActionTraceEvent) => void,
onHighlighted: (action: ActionTraceEvent | undefined) => void,
}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onHighlighted }) => {
}> = ({ context, boundaries, selectedAction, onSelected }) => {
const [measure, ref] = useMeasure<HTMLDivElement>();
const barsRef = React.useRef<HTMLDivElement | null>(null);
@ -92,7 +90,7 @@ export const Timeline: React.FunctionComponent<{
}, [context, boundaries, measure.width]);
const hoveredBar = hoveredBarIndex !== undefined ? bars[hoveredBarIndex] : undefined;
let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === (highlightedAction || selectedAction));
let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === selectedAction);
targetBar = hoveredBar || targetBar;
const findHoveredBarIndex = (x: number, y: number) => {
@ -132,14 +130,11 @@ export const Timeline: React.FunctionComponent<{
const index = findHoveredBarIndex(x, y);
setPreviewPoint({ x, clientY: event.clientY });
setHoveredBarIndex(index);
if (typeof index === 'number')
onHighlighted(bars[index].action);
};
const onMouseLeave = () => {
setPreviewPoint(undefined);
setHoveredBarIndex(undefined);
onHighlighted(undefined);
};
const onClick = (event: React.MouseEvent) => {

View file

@ -75,7 +75,7 @@
contain: size;
}
.workbench .header {
.header {
display: flex;
background-color: #000;
flex: none;

View file

@ -33,15 +33,11 @@ import { Timeline } from './timeline';
import './workbench.css';
import { toggleTheme } from '@web/theme';
export const Workbench: React.FunctionComponent<{
export const WorkbenchLoader: React.FunctionComponent<{
}> = () => {
const [traceURLs, setTraceURLs] = React.useState<string[]>([]);
const [uploadedTraceNames, setUploadedTraceNames] = React.useState<string[]>([]);
const [model, setModel] = React.useState<MultiTraceModel>(emptyModel);
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>('logs');
const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 });
const [dragOver, setDragOver] = React.useState<boolean>(false);
const [processingErrorMessage, setProcessingErrorMessage] = React.useState<string | null>(null);
@ -67,7 +63,6 @@ export const Workbench: React.FunctionComponent<{
window.history.pushState({}, '', href);
setTraceURLs(blobUrls);
setUploadedTraceNames(fileNames);
setSelectedAction(undefined);
setDragOver(false);
setProcessingErrorMessage(null);
};
@ -134,24 +129,6 @@ export const Workbench: React.FunctionComponent<{
})();
}, [traceURLs, uploadedTraceNames]);
const boundaries = { minimum: model.startTime, maximum: model.endTime };
// Leave some nice free space on the right hand side.
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
const { errors, warnings } = selectedAction ? modelUtil.stats(selectedAction) : { errors: 0, warnings: 0 };
const consoleCount = errors + warnings;
const networkCount = selectedAction ? modelUtil.resourcesForAction(selectedAction).length : 0;
const tabs = [
{ id: 'logs', title: 'Call', count: 0, render: () => <CallTab action={selectedAction} sdkLanguage={model.sdkLanguage} /> },
{ id: 'console', title: 'Console', count: consoleCount, render: () => <ConsoleTab action={selectedAction} /> },
{ id: 'network', title: 'Network', count: networkCount, render: () => <NetworkTab action={selectedAction} /> },
];
if (model.hasSource)
tabs.push({ id: 'source', title: 'Source', count: 0, render: () => <SourceTab action={selectedAction} /> });
return <div className='vbox workbench' onDragOver={event => { event.preventDefault(); setDragOver(true); }}>
<div className='hbox header'>
<div className='logo'>🎭</div>
@ -160,55 +137,7 @@ export const Workbench: React.FunctionComponent<{
<div className='spacer'></div>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</div>
<div style={{ paddingLeft: '20px', flex: 'none', borderBottom: '1px solid var(--vscode-panel-border)' }}>
<Timeline
context={model}
boundaries={boundaries}
selectedAction={selectedAction}
highlightedAction={highlightedAction}
onSelected={action => setSelectedAction(action)}
onHighlighted={action => setHighlightedAction(action)}
/>
</div>
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
<SplitView sidebarSize={300} orientation='horizontal'>
<SnapshotTab action={selectedAction} />
<TabbedPane tabs={tabs} selectedTab={selectedPropertiesTab} setSelectedTab={setSelectedPropertiesTab}/>
</SplitView>
<TabbedPane tabs={
[
{ id: 'actions', title: 'Actions', count: 0, render: () => <ActionList
sdkLanguage={model.sdkLanguage}
actions={model.actions}
selectedAction={selectedAction}
highlightedAction={highlightedAction}
onSelected={action => {
setSelectedAction(action);
}}
onHighlighted={action => setHighlightedAction(action)}
setSelectedTab={setSelectedPropertiesTab}
/> },
{ id: 'metadata', title: 'Metadata', count: 0, render: () => <div className='vbox'>
<div className='call-section' style={{ paddingTop: 2 }}>Time</div>
{model.wallTime && <div className='call-line'>start time: <span className='datetime' title={new Date(model.wallTime).toLocaleString()}>{new Date(model.wallTime).toLocaleString()}</span></div>}
<div className='call-line'>duration: <span className='number' title={msToString(model.endTime - model.startTime)}>{msToString(model.endTime - model.startTime)}</span></div>
<div className='call-section'>Browser</div>
<div className='call-line'>engine: <span className='string' title={model.browserName}>{model.browserName}</span></div>
{model.platform && <div className='call-line'>platform: <span className='string' title={model.platform}>{model.platform}</span></div>}
{model.options.userAgent && <div className='call-line'>user agent: <span className='datetime' title={model.options.userAgent}>{model.options.userAgent}</span></div>}
<div className='call-section'>Viewport</div>
{model.options.viewport && <div className='call-line'>width: <span className='number' title={String(!!model.options.viewport?.width)}>{model.options.viewport.width}</span></div>}
{model.options.viewport && <div className='call-line'>height: <span className='number' title={String(!!model.options.viewport?.height)}>{model.options.viewport.height}</span></div>}
<div className='call-line'>is mobile: <span className='boolean' title={String(!!model.options.isMobile)}>{String(!!model.options.isMobile)}</span></div>
{model.options.deviceScaleFactor && <div className='call-line'>device scale: <span className='number' title={String(model.options.deviceScaleFactor)}>{String(model.options.deviceScaleFactor)}</span></div>}
<div className='call-section'>Counts</div>
<div className='call-line'>pages: <span className='number'>{model.pages.length}</span></div>
<div className='call-line'>actions: <span className='number'>{model.actions.length}</span></div>
<div className='call-line'>events: <span className='number'>{model.events.length}</span></div>
</div> },
]
} selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/>
</SplitView>
<Workbench model={model} view='standalone'></Workbench>
{!!progress.total && <div className='progress'>
<div className='inner-progress' style={{ width: (100 * progress.done / progress.total) + '%' }}></div>
</div>}
@ -241,4 +170,91 @@ export const Workbench: React.FunctionComponent<{
</div>;
};
const emptyModel = new MultiTraceModel([]);
export const Workbench: React.FunctionComponent<{
model: MultiTraceModel,
view: 'embedded' | 'standalone'
}> = ({ model, view }) => {
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>('logs');
const activeAction = highlightedAction || selectedAction;
const boundaries = { minimum: model.startTime, maximum: model.endTime };
// Leave some nice free space on the right hand side.
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
const { errors, warnings } = activeAction ? modelUtil.stats(activeAction) : { errors: 0, warnings: 0 };
const consoleCount = errors + warnings;
const networkCount = activeAction ? modelUtil.resourcesForAction(activeAction).length : 0;
const tabs = [
{ id: 'logs', title: 'Call', count: 0, render: () => <CallTab action={activeAction} sdkLanguage={model.sdkLanguage} /> },
{ id: 'console', title: 'Console', count: consoleCount, render: () => <ConsoleTab action={activeAction} /> },
{ id: 'network', title: 'Network', count: networkCount, render: () => <NetworkTab action={activeAction} /> },
];
if (model.hasSource)
tabs.push({ id: 'source', title: 'Source', count: 0, render: () => <SourceTab action={selectedAction} /> });
return <div className='vbox'>
<div style={{ paddingLeft: '20px', flex: 'none', borderBottom: '1px solid var(--vscode-panel-border)' }}>
<Timeline
context={model}
boundaries={boundaries}
selectedAction={activeAction}
onSelected={action => setSelectedAction(action)}
/>
</div>
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
<SplitView sidebarSize={300} orientation={view === 'embedded' ? 'vertical' : 'horizontal'}>
<SnapshotTab action={activeAction} />
<TabbedPane tabs={tabs} selectedTab={selectedPropertiesTab} setSelectedTab={setSelectedPropertiesTab}/>
</SplitView>
<TabbedPane tabs={
[
{ id: 'actions', title: 'Actions', count: 0, render: () => <ActionList
sdkLanguage={model.sdkLanguage}
actions={model.actions}
selectedAction={selectedAction}
onSelected={action => {
setSelectedAction(action);
}}
onHighlighted={action => {
setHighlightedAction(action);
}}
setSelectedTab={setSelectedPropertiesTab}
/> },
{ id: 'metadata', title: 'Metadata', count: 0, render: () => <div className='vbox'>
<div className='call-section' style={{ paddingTop: 2 }}>Time</div>
{model.wallTime && <div className='call-line'>start time:<span className='call-value datetime' title={new Date(model.wallTime).toLocaleString()}>{new Date(model.wallTime).toLocaleString()}</span></div>}
<div className='call-line'>duration:<span className='call-value number' title={msToString(model.endTime - model.startTime)}>{msToString(model.endTime - model.startTime)}</span></div>
<div className='call-section'>Browser</div>
<div className='call-line'>engine:<span className='call-value string' title={model.browserName}>{model.browserName}</span></div>
{model.platform && <div className='call-line'>platform:<span className='call-value string' title={model.platform}>{model.platform}</span></div>}
{model.options.userAgent && <div className='call-line'>user agent:<span className='call-value datetime' title={model.options.userAgent}>{model.options.userAgent}</span></div>}
<div className='call-section'>Viewport</div>
{model.options.viewport && <div className='call-line'>width:<span className='call-value number' title={String(!!model.options.viewport?.width)}>{model.options.viewport.width}</span></div>}
{model.options.viewport && <div className='call-line'>height:<span className='call-value number' title={String(!!model.options.viewport?.height)}>{model.options.viewport.height}</span></div>}
<div className='call-line'>is mobile:<span className='call-value boolean' title={String(!!model.options.isMobile)}>{String(!!model.options.isMobile)}</span></div>
{model.options.deviceScaleFactor && <div className='call-line'>device scale:<span className='call-value number' title={String(model.options.deviceScaleFactor)}>{String(model.options.deviceScaleFactor)}</span></div>}
<div className='call-section'>Counts</div>
<div className='call-line'>pages:<span className='call-value number'>{model.pages.length}</span></div>
<div className='call-line'>actions:<span className='call-value number'>{model.actions.length}</span></div>
<div className='call-line'>events:<span className='call-value number'>{model.events.length}</span></div>
</div> },
]
} selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/>
</SplitView>
</div>;
};
export const emptyModel = new MultiTraceModel([]);
export async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`context?${params.toString()}`);
const contextEntry = await response.json() as ContextEntry;
return new MultiTraceModel([contextEntry]);
}

View file

@ -96,3 +96,12 @@ svg {
flex: auto;
position: relative;
}
.codicon-check {
color: var(--green);
}
.codicon-close,
.codicon-error {
color: var(--red);
}

View file

@ -0,0 +1,65 @@
/*
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.
*/
.list-view {
border-top: 1px solid var(--vscode-panel-border);
}
.list-view-content {
display: flex;
flex-direction: column;
flex: auto;
position: relative;
user-select: none;
overflow: auto;
outline: none;
}
.list-view-entry {
display: flex;
flex: none;
cursor: pointer;
align-items: center;
white-space: nowrap;
line-height: 28px;
padding-left: 5px;
}
.list-view-entry.highlighted,
.list-view-entry.selected {
background-color: var(--vscode-list-inactiveSelectionBackground);
}
.list-view-entry.highlighted {
background-color: var(--vscode-list-inactiveSelectionBackground);
}
.list-view-content:focus .list-view-entry.selected {
background-color: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
outline: 1px solid var(--vscode-focusBorder);
}
.list-view-content:focus .list-view-entry.selected * {
color: var(--vscode-list-activeSelectionForeground);
}
.list-view-empty {
flex: auto;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -0,0 +1,146 @@
/*
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 './listView.css';
export type ListViewProps = {
items: any[],
itemKey: (item: any) => string,
itemRender: (item: any) => React.ReactNode,
itemIcon?: (item: any) => string | undefined,
itemIndent?: (item: any) => number | undefined,
selectedItem?: any,
onAccepted?: (item: any) => void,
onSelected?: (item: any) => void,
onHighlighted?: (item: any | undefined) => void,
showNoItemsMessage?: boolean,
};
export const ListView: React.FC<ListViewProps> = ({
items = [],
itemKey,
itemRender,
itemIcon,
itemIndent,
selectedItem,
onAccepted,
onSelected,
onHighlighted,
showNoItemsMessage,
}) => {
const itemListRef = React.createRef<HTMLDivElement>();
const [highlightedItem, setHighlightedItem] = React.useState<any>();
return <div className='list-view vbox'>
<div
className='list-view-content'
tabIndex={0}
onDoubleClick={() => onAccepted?.(selectedItem)}
onKeyDown={event => {
if (event.key === 'Enter') {
onAccepted?.(selectedItem);
return;
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
return;
event.stopPropagation();
event.preventDefault();
const index = selectedItem ? items.indexOf(selectedItem) : -1;
let newIndex = index;
if (event.key === 'ArrowDown') {
if (index === -1)
newIndex = 0;
else
newIndex = Math.min(index + 1, items.length - 1);
}
if (event.key === 'ArrowUp') {
if (index === -1)
newIndex = items.length - 1;
else
newIndex = Math.max(index - 1, 0);
}
const element = itemListRef.current?.children.item(newIndex);
scrollIntoViewIfNeeded(element);
onSelected?.(items[newIndex]);
}}
ref={itemListRef}
>
{showNoItemsMessage && items.length === 0 && <div className='list-view-empty'>No items</div>}
{items.map(item => <ListItemView
key={itemKey(item)}
icon={itemIcon?.(item)}
indent={itemIndent?.(item)}
isHighlighted={item === highlightedItem}
isSelected={item === selectedItem}
onSelected={() => onSelected?.(item)}
onMouseEnter={() => {
setHighlightedItem(item);
onHighlighted?.(item);
}}
onMouseLeave={() => {
setHighlightedItem(undefined);
onHighlighted?.(undefined);
}}
>
{itemRender(item)}
</ListItemView>)}
</div>
</div>;
};
const ListItemView: React.FC<{
key: string,
icon: string | undefined,
indent: number | undefined,
isHighlighted: boolean,
isSelected: boolean,
onSelected: () => void,
onMouseEnter: () => void,
onMouseLeave: () => void,
children: React.ReactNode | React.ReactNode[],
}> = ({ key, icon, indent, onSelected, onMouseEnter, onMouseLeave, isHighlighted, isSelected, children }) => {
const selectedSuffix = isSelected ? ' selected' : '';
const highlightedSuffix = isHighlighted ? ' highlighted' : '';
const divRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (divRef.current && isSelected)
scrollIntoViewIfNeeded(divRef.current);
}, [isSelected]);
return <div
key={key}
className={'list-view-entry' + selectedSuffix + highlightedSuffix}
onClick={onSelected}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ref={divRef}
>
{indent ? <div style={{ minWidth: indent * 16 }}></div> : undefined}
<div className={'codicon ' + icon} style={{ minWidth: 16, marginRight: 4 }}></div>
{typeof children === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{children}</div> : children}
</div>;
};
function scrollIntoViewIfNeeded(element?: Element | null) {
if (!element)
return;
if ((element as any)?.scrollIntoViewIfNeeded)
(element as any).scrollIntoViewIfNeeded(false);
else
element?.scrollIntoView();
}

View file

@ -19,7 +19,7 @@ export function msToString(ms: number): string {
return '-';
if (ms === 0)
return '0ms';
return '0';
if (ms < 1000)
return ms.toFixed(0) + 'ms';

View file

@ -55,13 +55,13 @@ class TraceViewerPage {
}
async actionIconsText(action: string) {
const entry = await this.page.waitForSelector(`.action-entry:has-text("${action}")`);
const entry = await this.page.waitForSelector(`.list-view-entry:has-text("${action}")`);
await entry.waitForSelector('.action-icon-value:visible');
return await entry.$$eval('.action-icon-value:visible', ee => ee.map(e => e.textContent));
}
async actionIcons(action: string) {
return await this.page.waitForSelector(`.action-entry:has-text("${action}") .action-icons`);
return await this.page.waitForSelector(`.list-view-entry:has-text("${action}") .action-icons`);
}
async selectAction(title: string, ordinal: number = 0) {

View file

@ -143,29 +143,29 @@ test('should open console errors on click', async ({ showTraceViewer, browserNam
expect(await traceViewer.page.waitForSelector('.console-tab')).toBeTruthy();
});
test('should show params and return value', async ({ showTraceViewer, browserName }) => {
test('should show params and return value', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('page.evaluate');
await expect(traceViewer.callLines).toHaveText([
/page.evaluate/,
/wall time: [0-9/:,APM ]+/,
/duration: [\d]+ms/,
/expression: "\({↵ a↵ }\) => {↵ console\.log\(\'Info\'\);↵ console\.warn\(\'Warning\'\);↵ console/,
'isFunction: true',
'arg: {"a":"paramA","b":4}',
'value: "return paramA"'
/wall time:[0-9/:,APM ]+/,
/duration:[\d]+ms/,
/expression:"\({↵ a↵ }\) => {↵ console\.log\(\'Info\'\);↵ console\.warn\(\'Warning\'\);↵ console/,
'isFunction:true',
'arg:{"a":"paramA","b":4}',
'value:"return paramA"'
]);
await traceViewer.selectAction(`locator('button')`);
await expect(traceViewer.callLines).toContainText([
/expect.toHaveText/,
/wall time: [0-9/:,APM ]+/,
/duration: [\d]+ms/,
/locator: locator\('button'\)/,
/expression: "to.have.text"/,
/timeout: 10000/,
/matches: true/,
/received: "Click"/,
/wall time:[0-9/:,APM ]+/,
/duration:[\d]+ms/,
/locator:locator\('button'\)/,
/expression:"to.have.text"/,
/timeout:10000/,
/matches:true/,
/received:"Click"/,
]);
});
@ -174,12 +174,12 @@ test('should show null as a param', async ({ showTraceViewer, browserName }) =>
await traceViewer.selectAction('page.evaluate', 1);
await expect(traceViewer.callLines).toHaveText([
/page.evaluate/,
/wall time: [0-9/:,APM ]+/,
/duration: [\d]+ms/,
'expression: "() => 1 + 1"',
'isFunction: true',
'arg: null',
'value: 2'
/wall time:[0-9/:,APM ]+/,
/duration:[\d]+ms/,
'expression:"() => 1 + 1"',
'isFunction:true',
'arg:null',
'value:2'
]);
});
@ -604,15 +604,15 @@ test('should include metainfo', async ({ showTraceViewer, browserName }) => {
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.page.locator('text=Metadata').click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.getByText('start time')).toHaveText(/start time: [\d/,: ]+/);
await expect(callLine.getByText('duration')).toHaveText(/duration: [\dms]+/);
await expect(callLine.getByText('engine')).toHaveText(/engine: [\w]+/);
await expect(callLine.getByText('platform')).toHaveText(/platform: [\w]+/);
await expect(callLine.getByText('width')).toHaveText(/width: [\d]+/);
await expect(callLine.getByText('height')).toHaveText(/height: [\d]+/);
await expect(callLine.getByText('pages')).toHaveText(/pages: 1/);
await expect(callLine.getByText('actions')).toHaveText(/actions: [\d]+/);
await expect(callLine.getByText('events')).toHaveText(/events: [\d]+/);
await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/);
await expect(callLine.getByText('duration')).toHaveText(/duration:[\dms]+/);
await expect(callLine.getByText('engine')).toHaveText(/engine:[\w]+/);
await expect(callLine.getByText('platform')).toHaveText(/platform:[\w]+/);
await expect(callLine.getByText('width')).toHaveText(/width:[\d]+/);
await expect(callLine.getByText('height')).toHaveText(/height:[\d]+/);
await expect(callLine.getByText('pages')).toHaveText(/pages:1/);
await expect(callLine.getByText('actions')).toHaveText(/actions:[\d]+/);
await expect(callLine.getByText('events')).toHaveText(/events:[\d]+/);
});
test('should open two trace files', async ({ context, page, request, server, showTraceViewer }, testInfo) => {
@ -655,16 +655,16 @@ test('should open two trace files', async ({ context, page, request, server, sho
await traceViewer.page.locator('text=Metadata').click();
const callLine = traceViewer.page.locator('.call-line');
// Should get metadata from the context trace
await expect(callLine.getByText('start time')).toHaveText(/start time: [\d/,: ]+/);
await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/);
// duration in the metatadata section
await expect(callLine.getByText('duration').first()).toHaveText(/duration: [\dms]+/);
await expect(callLine.getByText('engine')).toHaveText(/engine: [\w]+/);
await expect(callLine.getByText('platform')).toHaveText(/platform: [\w]+/);
await expect(callLine.getByText('width')).toHaveText(/width: [\d]+/);
await expect(callLine.getByText('height')).toHaveText(/height: [\d]+/);
await expect(callLine.getByText('pages')).toHaveText(/pages: 1/);
await expect(callLine.getByText('actions')).toHaveText(/actions: 6/);
await expect(callLine.getByText('events')).toHaveText(/events: [\d]+/);
await expect(callLine.getByText('duration').first()).toHaveText(/duration:[\dms]+/);
await expect(callLine.getByText('engine')).toHaveText(/engine:[\w]+/);
await expect(callLine.getByText('platform')).toHaveText(/platform:[\w]+/);
await expect(callLine.getByText('width')).toHaveText(/width:[\d]+/);
await expect(callLine.getByText('height')).toHaveText(/height:[\d]+/);
await expect(callLine.getByText('pages')).toHaveText(/pages:1/);
await expect(callLine.getByText('actions')).toHaveText(/actions:6/);
await expect(callLine.getByText('events')).toHaveText(/events:[\d]+/);
});
test('should include requestUrl in route.fulfill', async ({ page, runAndTrace, browserName }) => {
@ -702,7 +702,7 @@ test('should include requestUrl in route.continue', async ({ page, runAndTrace,
await traceViewer.page.locator('.tab-label', { hasText: 'Call' }).click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
await expect(callLine.getByText(/^url: .*/)).toContainText(server.EMPTY_PAGE);
await expect(callLine.getByText(/^url:.*/)).toContainText(server.EMPTY_PAGE);
});
test('should include requestUrl in route.abort', async ({ page, runAndTrace, server }) => {