chore: add log/error tabs and counters (#26843)

This commit is contained in:
Pavel Feldman 2023-09-01 20:12:05 -07:00 committed by GitHub
parent ce3e0dcf84
commit 8c494e2519
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 259 additions and 58 deletions

View File

@ -19,12 +19,14 @@ import './attachmentsTab.css';
import { ImageDiffView } from '@web/components/imageDiffView';
import type { TestAttachment } from '@web/components/imageDiffView';
import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil';
import { PlaceholderPanel } from './placeholderPanel';
export const AttachmentsTab: React.FunctionComponent<{
model: MultiTraceModel | undefined,
}> = ({ model }) => {
if (!model)
return null;
const attachments = model?.actions.map(a => a.attachments || []).flat() || [];
if (!model || !attachments.length)
return <PlaceholderPanel text='No attachments' />;
return <div className='attachments-tab'>
{ model.actions.map((action, index) => <AttachmentsSection key={index} action={action} />) }
</div>;

View File

@ -22,16 +22,14 @@ import './callTab.css';
import { CopyToClipboard } from './copyToClipboard';
import { asLocator } from '@isomorphic/locatorGenerators';
import type { Language } from '@isomorphic/locatorGenerators';
import { ErrorMessage } from '@web/components/errorMessage';
import { PlaceholderPanel } from './placeholderPanel';
export const CallTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined,
sdkLanguage: Language | undefined,
}> = ({ action, sdkLanguage }) => {
if (!action)
return null;
const logs = action.log;
const error = action.error?.message;
return <PlaceholderPanel text='No action selected' />;
const params = { ...action.params };
// Strip down the waitForEventInfo data, we never need it.
delete params.info;
@ -40,8 +38,6 @@ export const CallTab: React.FunctionComponent<{
const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'Timed Out';
return <div className='call-tab'>
{!!error && <ErrorMessage error={error} />}
{!!error && <div className='call-section'>Call</div>}
<div className='call-line'>{action.apiName}</div>
{<>
<div className='call-section'>Time</div>
@ -58,14 +54,6 @@ export const CallTab: React.FunctionComponent<{
renderProperty(propertyToString(action, name, action.result[name], sdkLanguage), 'result-' + index)
)
}
<div className='call-section'>Log</div>
{
logs.map((logLine, index) => {
return <div key={index} className='call-line'>
{logLine}
</div>;
})
}
</div>;
};

View File

@ -23,8 +23,9 @@ import type { Boundaries } from '../geometry';
import { msToString } from '@web/uiUtils';
import { ansi2html } from '@web/ansi2html';
import type * as trace from '@trace/trace';
import { PlaceholderPanel } from './placeholderPanel';
type ConsoleEntry = {
export type ConsoleEntry = {
browserMessage?: trace.ConsoleMessageTraceEvent['initializer'],
browserError?: channels.SerializedError;
nodeMessage?: {
@ -36,13 +37,14 @@ type ConsoleEntry = {
timestamp: number;
};
type ConsoleTabModel = {
entries: ConsoleEntry[],
};
const ConsoleListView = ListView<ConsoleEntry>;
export const ConsoleTab: React.FunctionComponent<{
model: modelUtil.MultiTraceModel | undefined,
boundaries: Boundaries,
selectedTime: Boundaries | undefined,
}> = ({ model, boundaries, selectedTime }) => {
export function useConsoleTabModel(model: modelUtil.MultiTraceModel | undefined, selectedTime: Boundaries | undefined): ConsoleTabModel {
const { entries } = React.useMemo(() => {
if (!model)
return { entries: [] };
@ -89,9 +91,20 @@ export const ConsoleTab: React.FunctionComponent<{
return entries.filter(entry => entry.timestamp >= selectedTime.minimum && entry.timestamp <= selectedTime.maximum);
}, [entries, selectedTime]);
return { entries: filteredEntries };
}
export const ConsoleTab: React.FunctionComponent<{
boundaries: Boundaries,
consoleModel: ConsoleTabModel,
selectedTime: Boundaries | undefined,
}> = ({ consoleModel, boundaries }) => {
if (!consoleModel.entries.length)
return <PlaceholderPanel text='No console entries' />;
return <div className='console-tab'>
<ConsoleListView
items={filteredEntries}
items={consoleModel.entries}
isError={entry => entry.isError}
isWarning={entry => entry.isWarning}
render={entry => {

View File

@ -0,0 +1,61 @@
/**
* 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 { ErrorMessage } from '@web/components/errorMessage';
import * as React from 'react';
import type * as modelUtil from './modelUtil';
import { PlaceholderPanel } from './placeholderPanel';
import { renderAction } from './actionList';
import type { Language } from '@isomorphic/locatorGenerators';
import type { Boundaries } from '../geometry';
import { msToString } from '@web/uiUtils';
type ErrorsTabModel = {
errors: Map<string, modelUtil.ActionTraceEventInContext>;
};
export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): ErrorsTabModel {
return React.useMemo(() => {
const errors = new Map<string, modelUtil.ActionTraceEventInContext>();
for (const action of model?.actions || []) {
// Overwrite errors with the last one.
if (action.error?.message)
errors.set(action.error.message, action);
}
return { errors };
}, [model]);
}
export const ErrorsTab: React.FunctionComponent<{
errorsModel: ErrorsTabModel,
sdkLanguage: Language,
boundaries: Boundaries,
}> = ({ errorsModel, sdkLanguage, boundaries }) => {
if (!errorsModel.errors.size)
return <PlaceholderPanel text='No errors' />;
return <div className='fill' style={{ overflow: 'auto ' }}>
{[...errorsModel.errors.entries()].map(([message, action]) => {
return <div key={message}>
<div className='hbox' style={{ alignItems: 'center', padding: 5 }}>
<div style={{ color: 'var(--vscode-editorCodeLens-foreground)', marginRight: 5 }}>{msToString(action.startTime - boundaries.minimum)}</div>
{renderAction(action, sdkLanguage)}
</div>
<ErrorMessage error={message} />
</div>;
})}
</div>;
};

View File

@ -0,0 +1,34 @@
/**
* 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 { ActionTraceEvent } from '@trace/trace';
import * as React from 'react';
import { ListView } from '@web/components/listView';
import { PlaceholderPanel } from './placeholderPanel';
const LogList = ListView<string>;
export const LogTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined,
}> = ({ action }) => {
if (!action?.log.length)
return <PlaceholderPanel text='No log entries' />;
return <LogList
dataTestId='log-list'
items={action?.log || []}
render={logLine => logLine}
/>;
};

View File

@ -18,25 +18,21 @@ import type { Entry } from '@trace/har';
import { ListView } from '@web/components/listView';
import * as React from 'react';
import type { Boundaries } from '../geometry';
import type * as modelUtil from './modelUtil';
import './networkTab.css';
import { NetworkResourceDetails } from './networkResourceDetails';
import { bytesToString, msToString } from '@web/uiUtils';
import { PlaceholderPanel } from './placeholderPanel';
import type { MultiTraceModel } from './modelUtil';
const NetworkListView = ListView<Entry>;
type SortBy = 'start' | 'status' | 'method' | 'file' | 'duration' | 'size' | 'content-type';
type Sorting = { by: SortBy, negate: boolean};
type NetworkTabModel = {
resources: Entry[],
};
export const NetworkTab: React.FunctionComponent<{
model: modelUtil.MultiTraceModel | undefined,
boundaries: Boundaries,
selectedTime: Boundaries | undefined,
onEntryHovered: (entry: Entry | undefined) => void,
}> = ({ model, boundaries, selectedTime, onEntryHovered }) => {
const [resource, setResource] = React.useState<Entry | undefined>();
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedTime: Boundaries | undefined): NetworkTabModel {
const resources = React.useMemo(() => {
const resources = model?.resources || [];
const filtered = resources.filter(resource => {
@ -44,21 +40,37 @@ export const NetworkTab: React.FunctionComponent<{
return true;
return !!resource._monotonicTime && (resource._monotonicTime >= selectedTime.minimum && resource._monotonicTime <= selectedTime.maximum);
});
if (sorting)
sort(filtered, sorting);
return filtered;
}, [sorting, model, selectedTime]);
}, [model, selectedTime]);
return { resources };
}
export const NetworkTab: React.FunctionComponent<{
boundaries: Boundaries,
networkModel: NetworkTabModel,
onEntryHovered: (entry: Entry | undefined) => void,
}> = ({ boundaries, networkModel, onEntryHovered }) => {
const [resource, setResource] = React.useState<Entry | undefined>();
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
React.useMemo(() => {
if (sorting)
sort(networkModel.resources, sorting);
}, [networkModel.resources, sorting]);
const toggleSorting = React.useCallback((f: SortBy) => {
setSorting({ by: f, negate: sorting?.by === f ? !sorting.negate : false });
}, [sorting]);
if (!networkModel.resources.length)
return <PlaceholderPanel text='No network calls' />;
return <>
{!resource && <div className='vbox'>
<NetworkHeader sorting={sorting} toggleSorting={toggleSorting} />
<NetworkListView
dataTestId='network-request-list'
items={resources}
items={networkModel.resources}
render={entry => <NetworkResource boundaries={boundaries} resource={entry}></NetworkResource>}
onSelected={setResource}
onHighlighted={onEntryHovered}

View File

@ -0,0 +1,30 @@
/**
* 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';
export const PlaceholderPanel: React.FunctionComponent<{
text: string,
}> = ({ text }) => {
return <div className='fill' style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
fontWeight: 'bold',
opacity: 0.5,
}}>{text}</div>;
};

View File

@ -18,10 +18,12 @@ import { SplitView } from '@web/components/splitView';
import * as React from 'react';
import { ActionList } from './actionList';
import { CallTab } from './callTab';
import { ConsoleTab } from './consoleTab';
import { LogTab } from './logTab';
import { ErrorsTab, useErrorsTabModel } from './errorsTab';
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil from './modelUtil';
import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil';
import { NetworkTab } from './networkTab';
import { NetworkTab, useNetworkTabModel } from './networkTab';
import { SnapshotTab } from './snapshotTab';
import { SourceTab } from './sourceTab';
import { TabbedPane } from '@web/components/tabbedPane';
@ -49,7 +51,7 @@ export const Workbench: React.FunctionComponent<{
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEventInContext | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>(showSourcesFirst ? 'source' : 'call');
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('propertiesTab', showSourcesFirst ? 'source' : 'call');
const [isInspecting, setIsInspecting] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const activeAction = model ? highlightedAction || selectedAction : undefined;
@ -83,13 +85,20 @@ export const Workbench: React.FunctionComponent<{
setSelectedPropertiesTab(tab);
if (tab !== 'inspector')
setIsInspecting(false);
}, []);
}, [setSelectedPropertiesTab]);
const locatorPicked = React.useCallback((locator: string) => {
setHighlightedLocator(locator);
selectPropertiesTab('inspector');
}, [selectPropertiesTab]);
const consoleModel = useConsoleTabModel(model, selectedTime);
const networkModel = useNetworkTabModel(model, selectedTime);
const errorsModel = useErrorsTabModel(model);
const attachments = React.useMemo(() => {
return model?.actions.map(a => a.attachments || []).flat() || [];
}, [model]);
const sdkLanguage = model?.sdkLanguage || 'javascript';
const inspectorTab: TabbedPaneTabModel = {
@ -106,6 +115,17 @@ export const Workbench: React.FunctionComponent<{
title: 'Call',
render: () => <CallTab action={activeAction} sdkLanguage={sdkLanguage} />
};
const logTab: TabbedPaneTabModel = {
id: 'log',
title: 'Log',
render: () => <LogTab action={activeAction} />
};
const errorsTab: TabbedPaneTabModel = {
id: 'errors',
title: 'Errors',
errorCount: errorsModel.errors.size,
render: () => <ErrorsTab errorsModel={errorsModel} sdkLanguage={sdkLanguage} boundaries={boundaries} />
};
const sourceTab: TabbedPaneTabModel = {
id: 'source',
title: 'Source',
@ -119,34 +139,37 @@ export const Workbench: React.FunctionComponent<{
const consoleTab: TabbedPaneTabModel = {
id: 'console',
title: 'Console',
render: () => <ConsoleTab model={model} boundaries={boundaries} selectedTime={selectedTime} />
count: consoleModel.entries.length,
render: () => <ConsoleTab consoleModel={consoleModel} boundaries={boundaries} selectedTime={selectedTime} />
};
const networkTab: TabbedPaneTabModel = {
id: 'network',
title: 'Network',
render: () => <NetworkTab model={model} boundaries={boundaries} selectedTime={selectedTime} onEntryHovered={setHighlightedEntry}/>
count: networkModel.resources.length,
render: () => <NetworkTab boundaries={boundaries} networkModel={networkModel} onEntryHovered={setHighlightedEntry}/>
};
const attachmentsTab: TabbedPaneTabModel = {
id: 'attachments',
title: 'Attachments',
count: attachments.length,
render: () => <AttachmentsTab model={model} />
};
const tabs: TabbedPaneTabModel[] = showSourcesFirst ? [
inspectorTab,
sourceTab,
consoleTab,
networkTab,
callTab,
attachmentsTab,
] : [
const tabs: TabbedPaneTabModel[] = [
inspectorTab,
callTab,
logTab,
errorsTab,
consoleTab,
networkTab,
sourceTab,
attachmentsTab,
];
if (showSourcesFirst) {
const sourceTabIndex = tabs.indexOf(sourceTab);
tabs.splice(sourceTabIndex, 1);
tabs.splice(1, 0, sourceTab);
}
const { boundaries } = React.useMemo(() => {
const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 };

View File

@ -81,6 +81,14 @@ svg {
position: relative;
}
.fill {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.hbox {
display: flex;
flex: auto;

View File

@ -20,5 +20,5 @@
font-size: var(--vscode-editor-font-size);
background-color: var(--vscode-inputValidation-errorBackground);
white-space: pre;
overflow: auto;
padding: 10px;
}

View File

@ -28,13 +28,13 @@
display: flex;
flex: auto;
overflow: hidden;
position: relative;
}
.tabbed-pane-tab {
padding: 2px 10px 0 10px;
padding: 2px 6px 0 6px;
cursor: pointer;
display: flex;
flex: none;
align-items: center;
justify-content: center;
user-select: none;
@ -54,3 +54,21 @@
.tabbed-pane-tab.selected {
background-color: var(--vscode-tab-activeBackground);
}
.tabbed-pane-tab-counter {
padding: 0 4px;
background: var(--vscode-menu-separatorBackground);
border-radius: 8px;
height: 16px;
margin-left: 4px;
line-height: 16px;
min-width: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.tabbed-pane-tab-counter.error {
background: var(--vscode-list-errorForeground);
color: var(--vscode-button-foreground);
}

View File

@ -20,7 +20,9 @@ import * as React from 'react';
export interface TabbedPaneTabModel {
id: string;
title: string | JSX.Element;
title: string;
count?: number;
errorCount?: number;
component?: React.ReactElement;
render?: () => React.ReactElement;
}
@ -44,6 +46,8 @@ export const TabbedPane: React.FunctionComponent<{
<TabbedPaneTab
id={tab.id}
title={tab.title}
count={tab.count}
errorCount={tab.errorCount}
selected={selectedTab === tab.id}
onSelect={setSelectedTab}
></TabbedPaneTab>)),
@ -67,13 +71,18 @@ export const TabbedPane: React.FunctionComponent<{
export const TabbedPaneTab: React.FunctionComponent<{
id: string,
title: string | JSX.Element,
title: string,
count?: number,
errorCount?: number,
selected?: boolean,
onSelect: (id: string) => void
}> = ({ id, title, selected, onSelect }) => {
}> = ({ id, title, count, errorCount, selected, onSelect }) => {
return <div className={'tabbed-pane-tab ' + (selected ? 'selected' : '')}
onClick={() => onSelect(id)}
title={title}
key={id}>
<div className='tabbed-pane-tab-label'>{title}</div>
{!!count && <div className='tabbed-pane-tab-counter'>{count}</div>}
{!!errorCount && <div className='tabbed-pane-tab-counter error'>{errorCount}</div>}
</div>;
};

View File

@ -38,6 +38,7 @@ class TraceViewerPage {
actionTitles: Locator;
callLines: Locator;
consoleLines: Locator;
logLines: Locator;
consoleLineMessages: Locator;
consoleStacks: Locator;
stackFrames: Locator;
@ -47,6 +48,7 @@ class TraceViewerPage {
constructor(public page: Page) {
this.actionTitles = page.locator('.action-title');
this.callLines = page.locator('.call-tab .call-line');
this.logLines = page.getByTestId('log-list').locator('.list-view-entry');
this.consoleLines = page.locator('.console-line');
this.consoleLineMessages = page.locator('.console-line-message');
this.consoleStacks = page.locator('.console-stack');

View File

@ -122,7 +122,8 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => {
test('should contain action info', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('locator.click');
const logLines = await traceViewer.callLines.allTextContents();
await traceViewer.page.getByText('Log', { exact: true }).click();
const logLines = await traceViewer.logLines.allTextContents();
expect(logLines.length).toBeGreaterThan(10);
expect(logLines).toContain('attempting click action');
expect(logLines).toContain(' click action done');