diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx
index d10a9ee9c6..3ed7e26b90 100644
--- a/packages/trace-viewer/src/ui/attachmentsTab.tsx
+++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx
@@ -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 ;
return
{ model.actions.map((action, index) =>
) }
;
diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx
index e589709882..bb4cd2a0b4 100644
--- a/packages/trace-viewer/src/ui/callTab.tsx
+++ b/packages/trace-viewer/src/ui/callTab.tsx
@@ -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 ;
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
- {!!error &&
}
- {!!error &&
Call
}
{action.apiName}
{<>
Time
@@ -58,14 +54,6 @@ export const CallTab: React.FunctionComponent<{
renderProperty(propertyToString(action, name, action.result[name], sdkLanguage), 'result-' + index)
)
}
-
Log
- {
- logs.map((logLine, index) => {
- return
- {logLine}
-
;
- })
- }
;
};
diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx
index 7ec3238197..4541903542 100644
--- a/packages/trace-viewer/src/ui/consoleTab.tsx
+++ b/packages/trace-viewer/src/ui/consoleTab.tsx
@@ -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;
-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 ;
+
return
entry.isError}
isWarning={entry => entry.isWarning}
render={entry => {
diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx
new file mode 100644
index 0000000000..8025f4920c
--- /dev/null
+++ b/packages/trace-viewer/src/ui/errorsTab.tsx
@@ -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;
+};
+
+export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): ErrorsTabModel {
+ return React.useMemo(() => {
+ const errors = new Map();
+ 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 ;
+
+ return
+ {[...errorsModel.errors.entries()].map(([message, action]) => {
+ return
+
+
{msToString(action.startTime - boundaries.minimum)}
+ {renderAction(action, sdkLanguage)}
+
+
+
;
+ })}
+
;
+};
diff --git a/packages/trace-viewer/src/ui/logTab.tsx b/packages/trace-viewer/src/ui/logTab.tsx
new file mode 100644
index 0000000000..5f5c12bff7
--- /dev/null
+++ b/packages/trace-viewer/src/ui/logTab.tsx
@@ -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;
+
+export const LogTab: React.FunctionComponent<{
+ action: ActionTraceEvent | undefined,
+}> = ({ action }) => {
+ if (!action?.log.length)
+ return ;
+ return logLine}
+ />;
+};
diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx
index 0d714d3495..8d0ccb8749 100644
--- a/packages/trace-viewer/src/ui/networkTab.tsx
+++ b/packages/trace-viewer/src/ui/networkTab.tsx
@@ -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;
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();
- const [sorting, setSorting] = React.useState(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();
+ const [sorting, setSorting] = React.useState(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 ;
+
return <>
{!resource &&
}
onSelected={setResource}
onHighlighted={onEntryHovered}
diff --git a/packages/trace-viewer/src/ui/placeholderPanel.tsx b/packages/trace-viewer/src/ui/placeholderPanel.tsx
new file mode 100644
index 0000000000..2a74004893
--- /dev/null
+++ b/packages/trace-viewer/src/ui/placeholderPanel.tsx
@@ -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 {text}
;
+};
diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx
index bc21f2e3b6..85ea275ecd 100644
--- a/packages/trace-viewer/src/ui/workbench.tsx
+++ b/packages/trace-viewer/src/ui/workbench.tsx
@@ -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();
const [highlightedEntry, setHighlightedEntry] = React.useState();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions');
- const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState(showSourcesFirst ? 'source' : 'call');
+ const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting('propertiesTab', showSourcesFirst ? 'source' : 'call');
const [isInspecting, setIsInspecting] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState('');
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: () =>
};
+ const logTab: TabbedPaneTabModel = {
+ id: 'log',
+ title: 'Log',
+ render: () =>
+ };
+ const errorsTab: TabbedPaneTabModel = {
+ id: 'errors',
+ title: 'Errors',
+ errorCount: errorsModel.errors.size,
+ render: () =>
+ };
const sourceTab: TabbedPaneTabModel = {
id: 'source',
title: 'Source',
@@ -119,34 +139,37 @@ export const Workbench: React.FunctionComponent<{
const consoleTab: TabbedPaneTabModel = {
id: 'console',
title: 'Console',
- render: () =>
+ count: consoleModel.entries.length,
+ render: () =>
};
const networkTab: TabbedPaneTabModel = {
id: 'network',
title: 'Network',
- render: () =>
+ count: networkModel.resources.length,
+ render: () =>
};
const attachmentsTab: TabbedPaneTabModel = {
id: 'attachments',
title: 'Attachments',
+ count: attachments.length,
render: () =>
};
- 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 };
diff --git a/packages/web/src/common.css b/packages/web/src/common.css
index 1e0140aa5c..10c2b8df77 100644
--- a/packages/web/src/common.css
+++ b/packages/web/src/common.css
@@ -81,6 +81,14 @@ svg {
position: relative;
}
+.fill {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+}
+
.hbox {
display: flex;
flex: auto;
diff --git a/packages/web/src/components/errorMessage.css b/packages/web/src/components/errorMessage.css
index d50af176a2..a482ee0f14 100644
--- a/packages/web/src/components/errorMessage.css
+++ b/packages/web/src/components/errorMessage.css
@@ -20,5 +20,5 @@
font-size: var(--vscode-editor-font-size);
background-color: var(--vscode-inputValidation-errorBackground);
white-space: pre;
- overflow: auto;
+ padding: 10px;
}
diff --git a/packages/web/src/components/tabbedPane.css b/packages/web/src/components/tabbedPane.css
index d1989c5ecb..3a8a694ef3 100644
--- a/packages/web/src/components/tabbedPane.css
+++ b/packages/web/src/components/tabbedPane.css
@@ -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);
+}
diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx
index 4d4386844a..764ff2bcf1 100644
--- a/packages/web/src/components/tabbedPane.tsx
+++ b/packages/web/src/components/tabbedPane.tsx
@@ -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<{
)),
@@ -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 onSelect(id)}
+ title={title}
key={id}>
{title}
+ {!!count &&
{count}
}
+ {!!errorCount &&
{errorCount}
}
;
};
diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts
index 60797ab941..42ab162ba4 100644
--- a/tests/config/traceViewerFixtures.ts
+++ b/tests/config/traceViewerFixtures.ts
@@ -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');
diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts
index 3614505a74..6bc3e42956 100644
--- a/tests/library/trace-viewer.spec.ts
+++ b/tests/library/trace-viewer.spec.ts
@@ -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');