From a705d68c8a31659e78f7091d56bbb5a6c12ba2d3 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 16 Aug 2023 16:30:17 -0700 Subject: [PATCH] chore: filter actions, console and network based on the timeline window (#26509) --- packages/trace-viewer/src/ui/actionList.tsx | 14 +- packages/trace-viewer/src/ui/consoleTab.tsx | 25 +- packages/trace-viewer/src/ui/filmStrip.css | 8 +- packages/trace-viewer/src/ui/filmStrip.tsx | 26 +- packages/trace-viewer/src/ui/modelUtil.ts | 15 +- .../src/ui/networkResourceDetails.css | 4 - .../src/ui/networkResourceDetails.tsx | 5 +- packages/trace-viewer/src/ui/networkTab.tsx | 21 +- packages/trace-viewer/src/ui/timeline.css | 115 ++++---- packages/trace-viewer/src/ui/timeline.tsx | 258 ++++++++++-------- packages/trace-viewer/src/ui/uiModeView.tsx | 1 - packages/trace-viewer/src/ui/workbench.tsx | 31 ++- .../ui/{workbench.css => workbenchLoader.css} | 16 +- .../trace-viewer/src/ui/workbenchLoader.tsx | 4 +- packages/web/src/components/listView.tsx | 4 +- packages/web/src/components/treeView.tsx | 34 ++- tests/config/traceViewerFixtures.ts | 12 - tests/library/trace-viewer.spec.ts | 17 +- tests/playwright-test/reporter-html.spec.ts | 2 +- 19 files changed, 338 insertions(+), 274 deletions(-) rename packages/trace-viewer/src/ui/{workbench.css => workbenchLoader.css} (92%) diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index f6b5fcaf39..a79affb234 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -24,10 +24,12 @@ import type { Language } from '@isomorphic/locatorGenerators'; import type { TreeState } from '@web/components/treeView'; import { TreeView } from '@web/components/treeView'; import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil'; +import type { Boundaries } from '../geometry'; export interface ActionListProps { actions: ActionTraceEventInContext[], selectedAction: ActionTraceEventInContext | undefined, + selectedTime: Boundaries | undefined, sdkLanguage: Language | undefined; onSelected: (action: ActionTraceEventInContext) => void, onHighlighted: (action: ActionTraceEventInContext | undefined) => void, @@ -40,6 +42,7 @@ const ActionTreeView = TreeView; export const ActionList: React.FC = ({ actions, selectedAction, + selectedTime, sdkLanguage, onSelected, onHighlighted, @@ -63,15 +66,16 @@ export const ActionList: React.FC = ({ onSelected={item => onSelected(item.action!)} onHighlighted={item => onHighlighted(item?.action)} isError={item => !!item.action?.error?.message} + isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)} render={item => renderAction(item.action!, sdkLanguage, revealConsole, isLive || false)} />; }; -const renderAction = ( +export const renderAction = ( action: ActionTraceEvent, - sdkLanguage: Language | undefined, - revealConsole: () => void, - isLive: boolean, + sdkLanguage?: Language, + revealConsole?: () => void, + isLive?: boolean, ) => { const { errors, warnings } = modelUtil.stats(action); const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector, false /* isFrameLocator */, true /* playSafe */) : undefined; @@ -90,7 +94,7 @@ const renderAction = ( {action.method === 'goto' && action.params.url &&
{action.params.url}
}
{time || }
-
revealConsole()}> +
revealConsole?.()}> {!!errors &&
{errors}
} {!!warnings &&
{warnings}
}
diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index e36839df13..4adf898c2d 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -15,12 +15,12 @@ */ import type * as channels from '@protocol/channels'; -import type { ActionTraceEvent } from '@trace/trace'; import * as React from 'react'; import './consoleTab.css'; import * as modelUtil from './modelUtil'; import { ListView } from '@web/components/listView'; import { ansi2htmlMarkup } from '@web/components/errorMessage'; +import type { Boundaries } from '../geometry'; type ConsoleEntry = { message?: channels.ConsoleMessageInitializer; @@ -31,20 +31,18 @@ type ConsoleEntry = { isError: boolean; }, timestamp: number; - highlight: boolean; }; const ConsoleListView = ListView; export const ConsoleTab: React.FunctionComponent<{ model: modelUtil.MultiTraceModel | undefined, - action: ActionTraceEvent | undefined, -}> = ({ model, action }) => { + selectedTime: Boundaries | undefined, +}> = ({ model, selectedTime }) => { const { entries } = React.useMemo(() => { if (!model) return { entries: [] }; const entries: ConsoleEntry[] = []; - const actionEvents = action ? modelUtil.eventsForAction(action) : []; for (const event of model.events) { if (event.method !== 'console' && event.method !== 'pageError') continue; @@ -52,14 +50,12 @@ export const ConsoleTab: React.FunctionComponent<{ const { guid } = event.params.message; entries.push({ message: modelUtil.context(event).initializers[guid], - highlight: actionEvents.includes(event), timestamp: event.time, }); } if (event.method === 'pageError') { entries.push({ error: event.params.error, - highlight: actionEvents.includes(event), timestamp: event.time, }); } @@ -72,16 +68,21 @@ export const ConsoleTab: React.FunctionComponent<{ isError: event.type === 'stderr', }, timestamp: event.timestamp, - highlight: false, }); } entries.sort((a, b) => a.timestamp - b.timestamp); return { entries }; - }, [model, action]); + }, [model]); + + const filteredEntries = React.useMemo(() => { + if (!selectedTime) + return entries; + return entries.filter(entry => entry.timestamp >= selectedTime.minimum && entry.timestamp <= selectedTime.maximum); + }, [entries, selectedTime]); return
!!entry.error || entry.message?.type === 'error' || entry.nodeMessage?.isError || false} isWarning={entry => entry.message?.type === 'warning'} render={entry => { @@ -122,9 +123,7 @@ export const ConsoleTab: React.FunctionComponent<{
; } return null; - } - } - isHighlighted={entry => !!entry.highlight} + }} />
; }; diff --git a/packages/trace-viewer/src/ui/filmStrip.css b/packages/trace-viewer/src/ui/filmStrip.css index 246c34f4cd..2c82d15234 100644 --- a/packages/trace-viewer/src/ui/filmStrip.css +++ b/packages/trace-viewer/src/ui/filmStrip.css @@ -45,11 +45,15 @@ .film-strip-hover { position: absolute; top: 0; - right: 0; - bottom: 0; left: 0; background-color: white; box-shadow: rgba(0, 0, 0, 0.133) 0px 1.6px 10px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 10px 0px; z-index: 200; pointer-events: none; } + +.film-strip-hover-title { + padding: 2px 4px; + display: flex; + align-items: center; +} diff --git a/packages/trace-viewer/src/ui/filmStrip.tsx b/packages/trace-viewer/src/ui/filmStrip.tsx index 01584b03bc..b29e7c8147 100644 --- a/packages/trace-viewer/src/ui/filmStrip.tsx +++ b/packages/trace-viewer/src/ui/filmStrip.tsx @@ -19,7 +19,16 @@ import type { Boundaries, Size } from '../geometry'; import * as React from 'react'; import { useMeasure, upperBound } from '@web/uiUtils'; import type { PageEntry } from '../entries'; -import type { MultiTraceModel } from './modelUtil'; +import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; +import { renderAction } from './actionList'; +import type { Language } from '@isomorphic/locatorGenerators'; + +export type FilmStripPreviewPoint = { + x: number; + clientY: number; + action?: ActionTraceEventInContext; + sdkLanguage: Language; +}; const tileSize = { width: 200, height: 45 }; const frameMargin = 2.5; @@ -28,7 +37,7 @@ const rowHeight = tileSize.height + frameMargin * 2; export const FilmStrip: React.FunctionComponent<{ model?: MultiTraceModel, boundaries: Boundaries, - previewPoint?: { x: number, clientY: number }, + previewPoint?: FilmStripPreviewPoint, }> = ({ model, boundaries, previewPoint }) => { const [measure, ref] = useMeasure(); const lanesRef = React.useRef(null); @@ -45,7 +54,11 @@ export const FilmStrip: React.FunctionComponent<{ if (previewPoint !== undefined && screencastFrames) { const previewTime = boundaries.minimum + (boundaries.maximum - boundaries.minimum) * previewPoint.x / measure.width; previewImage = screencastFrames[upperBound(screencastFrames, previewTime, timeComparator) - 1]; - previewSize = previewImage ? inscribe({ width: previewImage.width, height: previewImage.height }, { width: (window.innerWidth * 3 / 4) | 0, height: (window.innerHeight * 3 / 4) | 0 }) : undefined; + const fitInto = { + width: Math.min(500, (window.innerWidth / 2) | 0), + height: Math.min(500, (window.innerHeight / 2) | 0), + }; + previewSize = previewImage ? inscribe({ width: previewImage.width, height: previewImage.height }, fitInto) : undefined; } return
@@ -59,12 +72,13 @@ export const FilmStrip: React.FunctionComponent<{ }
{previewImage && previewSize && previewPoint?.x !== undefined &&
- +
+ +
+ {previewPoint.action &&
{renderAction(previewPoint.action, previewPoint.sdkLanguage)}
}
} ; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 0b007a4d02..84ce665a13 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -24,7 +24,6 @@ const contextSymbol = Symbol('context'); const nextInContextSymbol = Symbol('next'); const prevInListSymbol = Symbol('prev'); const eventsSymbol = Symbol('events'); -const resourcesSymbol = Symbol('resources'); export type SourceLocation = { file: string; @@ -87,6 +86,7 @@ export class MultiTraceModel { this.resources = [...contexts.map(c => c.resources)].flat(); this.events.sort((a1, a2) => a1.time - a2.time); + this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!); this.sources = collectSources(this.actions); } } @@ -239,19 +239,6 @@ export function eventsForAction(action: ActionTraceEvent): EventTraceEvent[] { return result; } -export function resourcesForAction(action: ActionTraceEvent): ResourceSnapshot[] { - let result: ResourceSnapshot[] = (action as any)[resourcesSymbol]; - if (result) - return result; - - const nextAction = nextInContext(action); - result = context(action).resources.filter(resource => { - return typeof resource._monotonicTime === 'number' && resource._monotonicTime > action.startTime && (!nextAction || resource._monotonicTime < nextAction.startTime); - }); - (action as any)[resourcesSymbol] = result; - return result; -} - function collectSources(actions: trace.ActionTraceEvent[]): Map { const result = new Map(); for (const action of actions) { diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 45a61ab8f2..76d88872b8 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -30,10 +30,6 @@ flex: 1; } -.network-request.highlighted { - background-color: var(--vscode-list-inactiveSelectionBackground); -} - .network-request-title-status { padding: 0 2px; border-radius: 4px; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 68c9edbd69..ae3ba4447a 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -22,8 +22,7 @@ import type { Entry } from '@trace/har'; export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot, - highlighted: boolean, -}> = ({ resource, highlighted }) => { +}> = ({ resource }) => { const [expanded, setExpanded] = React.useState(false); const [requestBody, setRequestBody] = React.useState(null); const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string } | null>(null); @@ -86,7 +85,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{ }, [contentType, resource, resourceName, routeStatus]); return
+ className='network-request'>
{resource.time}ms
diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 6ddaeac76d..d5e3acfaed 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -15,23 +15,28 @@ */ import * as React from 'react'; -import type { ActionTraceEvent } from '@trace/trace'; -import * as modelUtil from './modelUtil'; +import type * as modelUtil from './modelUtil'; import { NetworkResourceDetails } from './networkResourceDetails'; import './networkTab.css'; +import type { Boundaries } from '../geometry'; export const NetworkTab: React.FunctionComponent<{ model: modelUtil.MultiTraceModel | undefined, - action: ActionTraceEvent | undefined, -}> = ({ model, action }) => { - const actionResources = action ? modelUtil.resourcesForAction(action) : []; - const resources = model?.resources || []; - return
{ + selectedTime: Boundaries | undefined, +}> = ({ model, selectedTime }) => { + const resources = React.useMemo(() => { + const resources = model?.resources || []; + if (!selectedTime) + return resources; + return resources.filter(resource => { + return !!resource._monotonicTime && (resource._monotonicTime >= selectedTime.minimum && resource._monotonicTime <= selectedTime.maximum); + }); + }, [model, selectedTime]); + return
{ resources.map((resource, index) => { return ; }) }
; diff --git a/packages/trace-viewer/src/ui/timeline.css b/packages/trace-viewer/src/ui/timeline.css index 4f010d56fb..e8922fb20f 100644 --- a/packages/trace-viewer/src/ui/timeline.css +++ b/packages/trace-viewer/src/ui/timeline.css @@ -25,6 +25,10 @@ margin-left: 10px; } +.timeline-view.dragging { + cursor: ew-resize; +} + .timeline-divider { position: absolute; width: 1px; @@ -42,16 +46,16 @@ pointer-events: none; } +.timeline-time-input { + cursor: pointer; +} + .timeline-lane { pointer-events: none; overflow: hidden; flex: none; - height: 30px; - position: relative; -} - -.timeline-lane.timeline-labels { height: 20px; + position: relative; } .timeline-grid { @@ -64,72 +68,26 @@ .timeline-lane.timeline-bars { pointer-events: auto; - margin-bottom: 10px; + margin-bottom: 5px; + height: 5px; overflow: visible; } .timeline-bar { position: absolute; - height: 9px; - border-radius: 2px; - min-width: 3px; + height: 2px; --action-color: gray; background-color: var(--action-color); } -.timeline-bar.selected { - filter: brightness(70%); - box-shadow: 0 0 0 1px var(--action-color); +.timeline-bar.action { + --action-color: var(--vscode-charts-red); } -.timeline-bar.frame_click, -.timeline-bar.frame_dblclick, -.timeline-bar.frame_hover, -.timeline-bar.frame_check, -.timeline-bar.frame_uncheck, -.timeline-bar.frame_tap { - --action-color: var(--vscode-charts-green); -} - -.timeline-bar.page_load, -.timeline-bar.page_domcontentloaded, -.timeline-bar.frame_fill, -.timeline-bar.frame_press, -.timeline-bar.frame_type, -.timeline-bar.frame_selectoption, -.timeline-bar.frame_setinputfiles { - --action-color: var(--orange); -} - -.timeline-bar.frame_loadstate { - display: none; -} - -.timeline-bar.frame_goto, -.timeline-bar.frame_setcontent, -.timeline-bar.frame_goback, -.timeline-bar.frame_goforward, -.timeline-bar.reload { +.timeline-bar.network { --action-color: var(--vscode-charts-blue); } -.timeline-bar.frame_evaluateexpression { - --action-color: var(--vscode-charts-yellow); -} - -.timeline-bar.frame_dialog { - --action-color: var(--transparent-blue); -} - -.timeline-bar.frame_navigated { - --action-color: var(--vscode-charts-blue); -} - -.timeline-bar.frame_waitforeventinfo, -.timeline-bar.page_waitforeventinfo { - --action-color: var(--vscode-editorCodeLens-foreground); -} - .timeline-label { position: absolute; top: 0; @@ -150,11 +108,46 @@ position: absolute; top: 0; bottom: 0; - width: 3px; - background-color: black; + pointer-events: none; + border-left: 3px solid var(--light-pink); +} + +.timeline-window { + display: flex; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; pointer-events: none; } -.timeline-marker.timeline-marker-hover { - background-color: var(--light-pink); +.timeline-window-center { + flex: auto; +} + +.timeline-window-drag { + height: 20px; + cursor: grab; + pointer-events: all; +} + +.timeline-window-curtain { + flex: none; + background-color: #3879d91a; +} + +.timeline-window-curtain.left { + border-right: 1px solid var(--vscode-panel-border); +} + +.timeline-window-curtain.right { + border-left: 1px solid var(--vscode-panel-border); +} + +.timeline-window-resizer { + flex: none; + width: 6px; + cursor: ew-resize; + pointer-events: all; } diff --git a/packages/trace-viewer/src/ui/timeline.tsx b/packages/trace-viewer/src/ui/timeline.tsx index 4c3fb42364..828095aca1 100644 --- a/packages/trace-viewer/src/ui/timeline.tsx +++ b/packages/trace-viewer/src/ui/timeline.tsx @@ -15,145 +15,186 @@ limitations under the License. */ -import type { EventTraceEvent } from '@trace/trace'; import { msToString, useMeasure } from '@web/uiUtils'; import * as React from 'react'; import type { Boundaries } from '../geometry'; import { FilmStrip } from './filmStrip'; +import type { FilmStripPreviewPoint } from './filmStrip'; import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; import './timeline.css'; -import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; +import type { Entry } from '@trace/har'; type TimelineBar = { action?: ActionTraceEventInContext; - event?: EventTraceEvent; + resource?: Entry; leftPosition: number; rightPosition: number; leftTime: number; rightTime: number; - type: string; - label: string; - title: string | undefined; - className: string; }; export const Timeline: React.FunctionComponent<{ model: MultiTraceModel | undefined, - selectedAction: ActionTraceEventInContext | undefined, + boundaries: Boundaries, onSelected: (action: ActionTraceEventInContext) => void, - hideTimelineBars?: boolean, + selectedTime: Boundaries | undefined, + setSelectedTime: (time: Boundaries | undefined) => void, sdkLanguage: Language, -}> = ({ model, selectedAction, onSelected, hideTimelineBars, sdkLanguage }) => { +}> = ({ model, boundaries, onSelected, selectedTime, setSelectedTime, sdkLanguage }) => { const [measure, ref] = useMeasure(); - const barsRef = React.useRef(null); + const [dragWindow, setDragWindow] = React.useState<{ startX: number, endX: number, pivot?: number, type: 'resize' | 'move' } | undefined>(); + const [previewPoint, setPreviewPoint] = React.useState(); - const [previewPoint, setPreviewPoint] = React.useState<{ x: number, clientY: number } | undefined>(); - const [hoveredBarIndex, setHoveredBarIndex] = React.useState(); - - const { boundaries, offsets } = React.useMemo(() => { - const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 }; - if (boundaries.minimum > boundaries.maximum) { - boundaries.minimum = 0; - boundaries.maximum = 30000; + const { offsets, curtainLeft, curtainRight } = React.useMemo(() => { + let activeWindow = selectedTime || boundaries; + if (dragWindow && dragWindow.startX !== dragWindow.endX) { + const time1 = positionToTime(measure.width, boundaries, dragWindow.startX); + const time2 = positionToTime(measure.width, boundaries, dragWindow.endX); + activeWindow = { minimum: Math.min(time1, time2), maximum: Math.max(time1, time2) }; } - // Leave some nice free space on the right hand side. - boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; - return { boundaries, offsets: calculateDividerOffsets(measure.width, boundaries) }; - }, [measure.width, model]); + const curtainLeft = timeToPosition(measure.width, boundaries, activeWindow.minimum); + const maxRight = timeToPosition(measure.width, boundaries, boundaries.maximum); + const curtainRight = maxRight - timeToPosition(measure.width, boundaries, activeWindow.maximum); + return { offsets: calculateDividerOffsets(measure.width, boundaries), curtainLeft, curtainRight }; + }, [selectedTime, boundaries, dragWindow, measure]); const bars = React.useMemo(() => { const bars: TimelineBar[] = []; for (const entry of model?.actions || []) { - const locator = asLocator(sdkLanguage || 'javascript', entry.params.selector, false /* isFrameLocator */, true /* playSafe */); - let detail = trimRight(locator || '', 50); - if (entry.method === 'goto') - detail = trimRight(entry.params.url || '', 50); + if (entry.class === 'Test') + continue; bars.push({ action: entry, leftTime: entry.startTime, - rightTime: entry.endTime, + rightTime: entry.endTime || boundaries.maximum, leftPosition: timeToPosition(measure.width, boundaries, entry.startTime), - rightPosition: timeToPosition(measure.width, boundaries, entry.endTime), - label: entry.apiName + ' ' + detail, - title: entry.endTime ? msToString(entry.endTime - entry.startTime) : 'Timed Out', - type: entry.type + '.' + entry.method, - className: `${entry.type}_${entry.method}`.toLowerCase() + rightPosition: timeToPosition(measure.width, boundaries, entry.endTime || boundaries.maximum), }); } - for (const event of model?.events || []) { - const startTime = event.time; + for (const resource of model?.resources || []) { + const startTime = resource._monotonicTime!; + const endTime = resource._monotonicTime! + resource.time; bars.push({ - event, + resource, leftTime: startTime, - rightTime: startTime, + rightTime: endTime, leftPosition: timeToPosition(measure.width, boundaries, startTime), - rightPosition: timeToPosition(measure.width, boundaries, startTime), - label: event.method, - title: undefined, - type: event.class + '.' + event.method, - className: `${event.class}_${event.method}`.toLowerCase() + rightPosition: timeToPosition(measure.width, boundaries, endTime), }); } return bars; - }, [model, boundaries, measure.width, sdkLanguage]); + }, [model, boundaries, measure]); - const hoveredBar = hoveredBarIndex !== undefined ? bars[hoveredBarIndex] : undefined; - let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === selectedAction); - targetBar = hoveredBar || targetBar; - - const findHoveredBarIndex = (x: number) => { + const onMouseDown = React.useCallback((event: React.MouseEvent) => { + setPreviewPoint(undefined); + if (!ref.current) + return; + const x = event.clientX - ref.current.getBoundingClientRect().left; const time = positionToTime(measure.width, boundaries, x); - const time1 = positionToTime(measure.width, boundaries, x - 5); - const time2 = positionToTime(measure.width, boundaries, x + 5); - let index: number | undefined; - let xDistance: number | undefined; - for (let i = 0; i < bars.length; i++) { - const bar = bars[i]; - const left = Math.max(bar.leftTime, time1); - const right = Math.min(bar.rightTime, time2); - const xMiddle = (bar.leftTime + bar.rightTime) / 2; - const xd = Math.abs(time - xMiddle); - if (left > right) - continue; - if (index === undefined || xd < xDistance!) { - index = i; - xDistance = xd; + const leftX = selectedTime ? timeToPosition(measure.width, boundaries, selectedTime.minimum) : 0; + const rightX = selectedTime ? timeToPosition(measure.width, boundaries, selectedTime.maximum) : 0; + + if (selectedTime && Math.abs(x - leftX) < 10) { + // Resize left. + setDragWindow({ startX: rightX, endX: x, type: 'resize' }); + } else if (selectedTime && Math.abs(x - rightX) < 10) { + // Resize right. + setDragWindow({ startX: leftX, endX: x, type: 'resize' }); + } else if (selectedTime && time > selectedTime.minimum && time < selectedTime.maximum && event.clientY - ref.current.getBoundingClientRect().top < 20) { + // Move window. + setDragWindow({ startX: leftX, endX: rightX, pivot: x, type: 'move' }); + } else { + // Create new. + setDragWindow({ startX: x, endX: x, type: 'resize' }); + } + }, [boundaries, measure, ref, selectedTime]); + + const onMouseMove = React.useCallback((event: React.MouseEvent) => { + if (!ref.current) + return; + const x = event.clientX - ref.current.getBoundingClientRect().left; + const time = positionToTime(measure.width, boundaries, x); + const action = model?.actions.findLast(action => action.startTime <= time); + if (!dragWindow) { + setPreviewPoint({ x, clientY: event.clientY, action, sdkLanguage }); + return; + } + if (!event.buttons) { + setDragWindow(undefined); + return; + } + + // When moving window reveal action under cursor. + if (action) + onSelected(action); + + let newDragWindow = dragWindow; + if (dragWindow.type === 'resize') { + newDragWindow = { ...dragWindow, endX: x }; + } else { + const delta = x - dragWindow.pivot!; + let startX = dragWindow.startX + delta; + let endX = dragWindow.endX + delta; + if (startX < 0) { + startX = 0; + endX = startX + (dragWindow.endX - dragWindow.startX); + } + if (endX > measure.width) { + endX = measure.width; + startX = endX - (dragWindow.endX - dragWindow.startX); + } + newDragWindow = { ...dragWindow, startX, endX, pivot: x }; + } + + setDragWindow(newDragWindow); + const time1 = positionToTime(measure.width, boundaries, newDragWindow.startX); + const time2 = positionToTime(measure.width, boundaries, newDragWindow.endX); + if (time1 !== time2) + setSelectedTime({ minimum: Math.min(time1, time2), maximum: Math.max(time1, time2) }); + }, [boundaries, dragWindow, measure, model, onSelected, ref, sdkLanguage, setSelectedTime]); + + const onMouseUp = React.useCallback(() => { + setPreviewPoint(undefined); + if (!dragWindow) + return; + if (dragWindow.startX !== dragWindow.endX) { + const time1 = positionToTime(measure.width, boundaries, dragWindow.startX); + const time2 = positionToTime(measure.width, boundaries, dragWindow.endX); + setSelectedTime({ minimum: Math.min(time1, time2), maximum: Math.max(time1, time2) }); + } else { + const time = positionToTime(measure.width, boundaries, dragWindow.startX); + const action = model?.actions.findLast(action => action.startTime <= time); + if (action) + onSelected(action); + // Include both, last action as well as the click position. + if (selectedTime && (time < selectedTime.minimum || time > selectedTime.maximum)) { + const minimum = action ? Math.max(Math.min(action.startTime, time), boundaries.minimum) : boundaries.minimum; + const maximum = action ? Math.min(Math.max(action.endTime, time), boundaries.maximum) : boundaries.maximum; + setSelectedTime({ minimum, maximum }); } } - return index; - }; + setDragWindow(undefined); + }, [boundaries, dragWindow, measure, model, selectedTime, setSelectedTime, onSelected]); - const onMouseMove = (event: React.MouseEvent) => { - if (!ref.current) - return; - const x = event.clientX - ref.current.getBoundingClientRect().left; - const index = findHoveredBarIndex(x); - setPreviewPoint({ x, clientY: event.clientY }); - setHoveredBarIndex(index); - }; - - const onMouseLeave = () => { + const onMouseLeave = React.useCallback(() => { setPreviewPoint(undefined); - setHoveredBarIndex(undefined); - }; + }, []); - const onClick = (event: React.MouseEvent) => { - setPreviewPoint(undefined); - if (!ref.current) - return; - const x = event.clientX - ref.current.getBoundingClientRect().left; - const index = findHoveredBarIndex(x); - if (index === undefined) - return; - const entry = bars[index].action; - if (entry) - onSelected(entry); - }; + const onDoubleClick = React.useCallback(() => { + setSelectedTime(undefined); + }, [setSelectedTime]); return
-
+
{ offsets.map((offset, index) => { return
@@ -161,37 +202,32 @@ export const Timeline: React.FunctionComponent<{
; }) }
- {!hideTimelineBars &&
{ + {
{ bars.map((bar, index) => { return
- {bar.label} -
; - }) - }
} - {!hideTimelineBars &&
{ - bars.map((bar, index) => { - return
; }) }
} -
+ }} /> +
+
+
+
+
+
+
+
+
; }; @@ -234,10 +270,6 @@ function positionToTime(clientWidth: number, boundaries: Boundaries, x: number): return x / clientWidth * (boundaries.maximum - boundaries.minimum) + boundaries.minimum; } -function trimRight(s: string, maxLength: number): string { - return s.length <= maxLength ? s : s.substring(0, maxLength - 1) + '\u2026'; -} - function barTop(bar: TimelineBar): number { - return bar.event ? 22 : (bar.action?.method === 'waitForEventInfo' ? 0 : 11); + return bar.resource ? 5 : 0; } diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index ddc6ae1656..219fcd06be 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -564,7 +564,6 @@ const TraceView: React.FC<{ return void, isLive?: boolean, drawer?: 'bottom' | 'right', -}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, drawer }) => { +}> = ({ model, hideStackFrames, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, drawer }) => { const [selectedAction, setSelectedAction] = React.useState(undefined); const [highlightedAction, setHighlightedAction] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState(showSourcesFirst ? 'source' : 'call'); const activeAction = model ? highlightedAction || selectedAction : undefined; + const [selectedTime, setSelectedTime] = React.useState(); const sources = React.useMemo(() => model?.sources || new Map(), [model]); @@ -88,12 +88,12 @@ export const Workbench: React.FunctionComponent<{ const consoleTab: TabbedPaneTabModel = { id: 'console', title: 'Console', - render: () => + render: () => }; const networkTab: TabbedPaneTabModel = { id: 'network', title: 'Network', - render: () => + render: () => }; const attachmentsTab: TabbedPaneTabModel = { id: 'attachments', @@ -115,15 +115,27 @@ export const Workbench: React.FunctionComponent<{ attachmentsTab, ]; - return
+ const { boundaries } = React.useMemo(() => { + const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 }; + if (boundaries.minimum > boundaries.maximum) { + boundaries.minimum = 0; + boundaries.maximum = 30000; + } + // Leave some nice free space on the right hand side. + boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; + return { boundaries }; + }, [model]); + + return
- + setSelectedPropertiesTab('console')} diff --git a/packages/trace-viewer/src/ui/workbench.css b/packages/trace-viewer/src/ui/workbenchLoader.css similarity index 92% rename from packages/trace-viewer/src/ui/workbench.css rename to packages/trace-viewer/src/ui/workbenchLoader.css index 50043ca9d0..b405f64502 100644 --- a/packages/trace-viewer/src/ui/workbench.css +++ b/packages/trace-viewer/src/ui/workbenchLoader.css @@ -78,10 +78,6 @@ body.dark-mode .drop-target { height: 100%; } -.workbench { - contain: size; -} - .header { display: flex; background-color: #000; @@ -92,21 +88,25 @@ body.dark-mode .drop-target { color: #cccccc; } -.workbench .header .toolbar-button { +.workbench-loader { + contain: size; +} + +.workbench-loader .header .toolbar-button { margin: 12px; padding: 8px 4px; } -.workbench .logo { +.workbench-loader .logo { font-size: 20px; margin-left: 16px; } -.workbench .product { +.workbench-loader .product { font-weight: 600; margin-left: 16px; } -.workbench .header .title { +.workbench-loader .header .title { margin-left: 16px; } diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index f422b3f553..905942ca23 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -18,7 +18,7 @@ import { ToolbarButton } from '@web/components/toolbarButton'; import * as React from 'react'; import type { ContextEntry } from '../entries'; import { MultiTraceModel } from './modelUtil'; -import './workbench.css'; +import './workbenchLoader.css'; import { toggleTheme } from '@web/theme'; import { Workbench } from './workbench'; import { connect } from './wsPort'; @@ -137,7 +137,7 @@ export const WorkbenchLoader: React.FunctionComponent<{ })(); }, [isServer, traceURLs, uploadedTraceNames]); - return
{ event.preventDefault(); setDragOver(true); }}> + return
{ event.preventDefault(); setDragOver(true); }}>
🎭
Playwright
diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index 8f4c4a3226..5e3d5f3670 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -25,7 +25,6 @@ export type ListViewProps = { indent?: (item: T, index: number) => number | undefined, isError?: (item: T, index: number) => boolean, isWarning?: (item: T, index: number) => boolean, - isHighlighted?: (item: T, index: number) => boolean, selectedItem?: T, onAccepted?: (item: T, index: number) => void, onSelected?: (item: T, index: number) => void, @@ -44,7 +43,6 @@ export function ListView({ icon, isError, isWarning, - isHighlighted, indent, selectedItem, onAccepted, @@ -113,7 +111,7 @@ export function ListView({ {noItemsMessage && items.length === 0 &&
{noItemsMessage}
} {items.map((item, index) => { const selectedSuffix = selectedItem === item ? ' selected' : ''; - const highlightedSuffix = isHighlighted?.(item, index) || highlightedItem === item ? ' highlighted' : ''; + const highlightedSuffix = highlightedItem === item ? ' highlighted' : ''; const errorSuffix = isError?.(item, index) ? ' error' : ''; const warningSuffix = isWarning?.(item, index) ? ' warning' : ''; const indentation = indent?.(item, index) || 0; diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx index 56b09eac3f..0dad1f6a3e 100644 --- a/packages/web/src/components/treeView.tsx +++ b/packages/web/src/components/treeView.tsx @@ -32,6 +32,7 @@ export type TreeViewProps = { render: (item: T) => React.ReactNode, icon?: (item: T) => string | undefined, isError?: (item: T) => boolean, + isVisible?: (item: T) => boolean, selectedItem?: T, onAccepted?: (item: T) => void, onSelected?: (item: T) => void, @@ -50,6 +51,7 @@ export function TreeView({ render, icon, isError, + isVisible, selectedItem, onAccepted, onSelected, @@ -61,13 +63,43 @@ export function TreeView({ autoExpandDepth, }: TreeViewProps) { const treeItems = React.useMemo(() => { + // Expand all ancestors of the selected item. for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent) treeState.expandedItems.set(item.id, true); return flattenTree(rootItem, treeState.expandedItems, autoExpandDepth || 0); }, [rootItem, selectedItem, treeState, autoExpandDepth]); + // Filter visible items. + const visibleItems = React.useMemo(() => { + if (!isVisible) + return [...treeItems.keys()]; + const cachedVisible = new Map(); + const visit = (item: TreeItem): boolean => { + const cachedResult = cachedVisible.get(item); + if (cachedResult !== undefined) + return cachedResult; + + let hasVisibleChildren = item.children.some(child => visit(child)); + for (const child of item.children) { + const result = visit(child); + hasVisibleChildren = hasVisibleChildren || result; + } + const result = isVisible(item as T) || hasVisibleChildren; + cachedVisible.set(item, result); + return result; + }; + for (const item of treeItems.keys()) + visit(item); + const result: T[] = []; + for (const item of treeItems.keys()) { + if (isVisible(item)) + result.push(item); + } + return result; + }, [treeItems, isVisible]); + return item.id} dataTestId={dataTestId} render={item => { diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 70d81b8997..7c3d029687 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -85,18 +85,6 @@ class TraceViewerPage { await this.page.click('text="Network"'); } - async eventBars() { - await this.page.waitForSelector('.timeline-bar.event:visible'); - const list = await this.page.$$eval('.timeline-bar.event:visible', ee => ee.map(e => e.className)); - const set = new Set(); - for (const item of list) { - for (const className of item.split(' ')) - set.add(className); - } - const result = [...set]; - return result.sort(); - } - @step async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise { await this.selectAction(actionName, ordinal); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 901a4dce2f..712f9bfa96 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -128,10 +128,11 @@ test('should contain action info', async ({ showTraceViewer }) => { expect(logLines).toContain(' click action done'); }); -test('should render events', async ({ showTraceViewer }) => { - const traceViewer = await showTraceViewer([traceFile]); - const events = await traceViewer.eventBars(); - expect(events).toContain('browsercontext_console'); +test('should render network bars', async ({ page, runAndTrace, server }) => { + const traceViewer = await runAndTrace(async () => { + await page.goto(server.EMPTY_PAGE); + }); + await expect(traceViewer.page.locator('.timeline-bar.network')).toHaveCount(1); }); test('should render console', async ({ showTraceViewer, browserName }) => { @@ -157,10 +158,10 @@ test('should render console', async ({ showTraceViewer, browserName }) => { await traceViewer.selectAction('page.evaluate'); const listViews = traceViewer.page.locator('.console-tab').locator('.list-view-entry'); - await expect(listViews.nth(0)).toHaveClass('list-view-entry highlighted'); - await expect(listViews.nth(1)).toHaveClass('list-view-entry highlighted warning'); - await expect(listViews.nth(2)).toHaveClass('list-view-entry highlighted error'); - await expect(listViews.nth(3)).toHaveClass('list-view-entry highlighted error'); + await expect(listViews.nth(0)).toHaveClass('list-view-entry'); + await expect(listViews.nth(1)).toHaveClass('list-view-entry warning'); + await expect(listViews.nth(2)).toHaveClass('list-view-entry error'); + await expect(listViews.nth(3)).toHaveClass('list-view-entry error'); // Firefox can insert layout error here. await expect(listViews.last()).toHaveClass('list-view-entry'); }); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 23ba5bf395..dca8a20a13 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -461,7 +461,7 @@ for (const useIntermediateMergeReport of [false, true] as const) { await showReport(); await page.click('text=passes'); await page.click('img'); - await expect(page.locator('.workbench .title')).toHaveText('a.test.js:3 › passes'); + await expect(page.locator('.workbench-loader .title')).toHaveText('a.test.js:3 › passes'); }); test('should show multi trace source', async ({ runInlineTest, page, server, showReport }) => {