From 8c494e25191a2da940afe1dabde920a78b94cbb4 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 1 Sep 2023 20:12:05 -0700 Subject: [PATCH] chore: add log/error tabs and counters (#26843) --- .../trace-viewer/src/ui/attachmentsTab.tsx | 6 +- packages/trace-viewer/src/ui/callTab.tsx | 16 +---- packages/trace-viewer/src/ui/consoleTab.tsx | 27 +++++--- packages/trace-viewer/src/ui/errorsTab.tsx | 61 +++++++++++++++++++ packages/trace-viewer/src/ui/logTab.tsx | 34 +++++++++++ packages/trace-viewer/src/ui/networkTab.tsx | 40 +++++++----- .../trace-viewer/src/ui/placeholderPanel.tsx | 30 +++++++++ packages/trace-viewer/src/ui/workbench.tsx | 51 +++++++++++----- packages/web/src/common.css | 8 +++ packages/web/src/components/errorMessage.css | 2 +- packages/web/src/components/tabbedPane.css | 22 ++++++- packages/web/src/components/tabbedPane.tsx | 15 ++++- tests/config/traceViewerFixtures.ts | 2 + tests/library/trace-viewer.spec.ts | 3 +- 14 files changed, 259 insertions(+), 58 deletions(-) create mode 100644 packages/trace-viewer/src/ui/errorsTab.tsx create mode 100644 packages/trace-viewer/src/ui/logTab.tsx create mode 100644 packages/trace-viewer/src/ui/placeholderPanel.tsx 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');