chore: filter actions, console and network based on the timeline window (#26509)

This commit is contained in:
Pavel Feldman 2023-08-16 16:30:17 -07:00 committed by GitHub
parent 0149c7d56c
commit a705d68c8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 338 additions and 274 deletions

View File

@ -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<ActionTreeItem>;
export const ActionList: React.FC<ActionListProps> = ({
actions,
selectedAction,
selectedTime,
sdkLanguage,
onSelected,
onHighlighted,
@ -63,15 +66,16 @@ export const ActionList: React.FC<ActionListProps> = ({
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 && <div className='action-url' title={action.params.url}>{action.params.url}</div>}
</div>
<div className='action-duration' style={{ flex: 'none' }}>{time || <span className='codicon codicon-loading'></span>}</div>
<div className='action-icons' onClick={() => revealConsole()}>
<div className='action-icons' onClick={() => revealConsole?.()}>
{!!errors && <div className='action-icon'><span className='codicon codicon-error'></span><span className="action-icon-value">{errors}</span></div>}
{!!warnings && <div className='action-icon'><span className='codicon codicon-warning'></span><span className="action-icon-value">{warnings}</span></div>}
</div>

View File

@ -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<ConsoleEntry>;
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 <div className='console-tab'>
<ConsoleListView
items={entries}
items={filteredEntries}
isError={entry => !!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<{
</div>;
}
return null;
}
}
isHighlighted={entry => !!entry.highlight}
}}
/>
</div>;
};

View File

@ -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;
}

View File

@ -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<HTMLDivElement>();
const lanesRef = React.useRef<HTMLDivElement>(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 <div className='film-strip' ref={ref}>
@ -59,12 +72,13 @@ export const FilmStrip: React.FunctionComponent<{
}</div>
{previewImage && previewSize && previewPoint?.x !== undefined &&
<div className='film-strip-hover' style={{
width: previewSize.width,
height: previewSize.height,
top: measure.bottom + 5,
left: Math.min(previewPoint!.x, measure.width - previewSize.width - 10),
}}>
<img src={`sha1/${previewImage.sha1}`} width={previewSize.width} height={previewSize.height} />
<div style={{ width: previewSize.width, height: previewSize.height }}>
<img src={`sha1/${previewImage.sha1}`} width={previewSize.width} height={previewSize.height} />
</div>
{previewPoint.action && <div className='film-strip-hover-title'>{renderAction(previewPoint.action, previewPoint.sdkLanguage)}</div>}
</div>
}
</div>;

View File

@ -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<string, SourceModel> {
const result = new Map<string, SourceModel>();
for (const action of actions) {

View File

@ -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;

View File

@ -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<string | null>(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 <div
className={'network-request' + (highlighted ? ' highlighted' : '')}>
className='network-request'>
<Expandable expanded={expanded} setExpanded={setExpanded} title={ renderTitle() }>
<div className='network-request-details'>
<div className='network-request-details-time'>{resource.time}ms</div>

View File

@ -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 <div className='network-tab'>{
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 <div className='network-tab'> {
resources.map((resource, index) => {
return <NetworkResourceDetails
resource={resource}
key={index}
highlighted={actionResources.includes(resource)}
/>;
})
}</div>;

View File

@ -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;
}

View File

@ -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<HTMLDivElement>();
const barsRef = React.useRef<HTMLDivElement | null>(null);
const [dragWindow, setDragWindow] = React.useState<{ startX: number, endX: number, pivot?: number, type: 'resize' | 'move' } | undefined>();
const [previewPoint, setPreviewPoint] = React.useState<FilmStripPreviewPoint | undefined>();
const [previewPoint, setPreviewPoint] = React.useState<{ x: number, clientY: number } | undefined>();
const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>();
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 <div style={{ flex: 'none', borderBottom: '1px solid var(--vscode-panel-border)' }}>
<div ref={ref} className='timeline-view' onMouseMove={onMouseMove} onMouseOver={onMouseMove} onMouseLeave={onMouseLeave} onClick={onClick}>
<div
ref={ref}
className={'timeline-view' + (dragWindow ? ' dragging' : '')}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onDoubleClick={onDoubleClick}>
<div className='timeline-grid'>{
offsets.map((offset, index) => {
return <div key={index} className='timeline-divider' style={{ left: offset.position + 'px' }}>
@ -161,37 +202,32 @@ export const Timeline: React.FunctionComponent<{
</div>;
})
}</div>
{!hideTimelineBars && <div className='timeline-lane timeline-labels'>{
{<div className='timeline-lane timeline-bars'>{
bars.map((bar, index) => {
return <div key={index}
className={'timeline-label ' + bar.className + (targetBar === bar ? ' selected' : '')}
style={{
left: bar.leftPosition,
maxWidth: 100,
}}
>
{bar.label}
</div>;
})
}</div>}
{!hideTimelineBars && <div className='timeline-lane timeline-bars' ref={barsRef}>{
bars.map((bar, index) => {
return <div key={index}
className={'timeline-bar ' + (bar.action ? 'action ' : '') + (bar.event ? 'event ' : '') + bar.className + (targetBar === bar ? ' selected' : '')}
className={'timeline-bar ' + (bar.action ? 'action ' : '') + (bar.resource ? 'network ' : '')}
style={{
left: bar.leftPosition + 'px',
width: Math.max(1, bar.rightPosition - bar.leftPosition) + 'px',
top: barTop(bar) + 'px',
}}
title={bar.title}
></div>;
})
}</div>}
<FilmStrip model={model} boundaries={boundaries} previewPoint={previewPoint} />
<div className='timeline-marker timeline-marker-hover' style={{
<div className='timeline-marker' style={{
display: (previewPoint !== undefined) ? 'block' : 'none',
left: (previewPoint?.x || 0) + 'px',
}}></div>
}} />
<div className='timeline-window'>
<div className='timeline-window-curtain left' style={{ width: curtainLeft }}></div>
<div className='timeline-window-resizer'></div>
<div className='timeline-window-center'>
<div className='timeline-window-drag'></div>
</div>
<div className='timeline-window-resizer'></div>
<div className='timeline-window-curtain right' style={{ width: curtainRight }}></div>
</div>
</div>
</div>;
};
@ -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;
}

View File

@ -564,7 +564,6 @@ const TraceView: React.FC<{
return <Workbench
key='workbench'
model={model?.model}
hideTimelineBars={true}
hideStackFrames={true}
showSourcesFirst={true}
rootDir={rootDir}

View File

@ -27,13 +27,12 @@ import { SourceTab } from './sourceTab';
import { TabbedPane } from '@web/components/tabbedPane';
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { Timeline } from './timeline';
import './workbench.css';
import { MetadataView } from './metadataView';
import { AttachmentsTab } from './attachmentsTab';
import type { Boundaries } from '../geometry';
export const Workbench: React.FunctionComponent<{
model?: MultiTraceModel,
hideTimelineBars?: boolean,
hideStackFrames?: boolean,
showSourcesFirst?: boolean,
rootDir?: string,
@ -42,12 +41,13 @@ export const Workbench: React.FunctionComponent<{
onSelectionChanged?: (action: ActionTraceEventInContext) => 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<ActionTraceEventInContext | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEventInContext | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>(showSourcesFirst ? 'source' : 'call');
const activeAction = model ? highlightedAction || selectedAction : undefined;
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
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: () => <ConsoleTab model={model} action={activeAction} />
render: () => <ConsoleTab model={model} selectedTime={selectedTime} />
};
const networkTab: TabbedPaneTabModel = {
id: 'network',
title: 'Network',
render: () => <NetworkTab model={model} action={activeAction} />
render: () => <NetworkTab model={model} selectedTime={selectedTime} />
};
const attachmentsTab: TabbedPaneTabModel = {
id: 'attachments',
@ -115,15 +115,27 @@ export const Workbench: React.FunctionComponent<{
attachmentsTab,
];
return <div className='vbox'>
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 <div className='vbox workbench'>
<Timeline
model={model}
selectedAction={activeAction}
boundaries={boundaries}
onSelected={onActionSelected}
hideTimelineBars={hideTimelineBars}
sdkLanguage={sdkLanguage}
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
/>
<SplitView sidebarSize={250} orientation={drawer === 'bottom' ? 'vertical' : 'horizontal'}>
<SplitView sidebarSize={drawer === 'bottom' ? 250 : 400} orientation={drawer === 'bottom' ? 'vertical' : 'horizontal'}>
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<SnapshotTab action={activeAction} sdkLanguage={sdkLanguage} testIdAttributeName={model?.testIdAttributeName || 'data-testid'} />
<TabbedPane tabs={
@ -135,6 +147,7 @@ export const Workbench: React.FunctionComponent<{
sdkLanguage={sdkLanguage}
actions={model?.actions || []}
selectedAction={model ? selectedAction : undefined}
selectedTime={selectedTime}
onSelected={onActionSelected}
onHighlighted={setHighlightedAction}
revealConsole={() => setSelectedPropertiesTab('console')}

View File

@ -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;
}

View File

@ -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 <div className='vbox workbench' onDragOver={event => { event.preventDefault(); setDragOver(true); }}>
return <div className='vbox workbench-loader' onDragOver={event => { event.preventDefault(); setDragOver(true); }}>
<div className='hbox header'>
<div className='logo'>🎭</div>
<div className='product'>Playwright</div>

View File

@ -25,7 +25,6 @@ export type ListViewProps<T> = {
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<T>({
icon,
isError,
isWarning,
isHighlighted,
indent,
selectedItem,
onAccepted,
@ -113,7 +111,7 @@ export function ListView<T>({
{noItemsMessage && items.length === 0 && <div className='list-view-empty'>{noItemsMessage}</div>}
{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;

View File

@ -32,6 +32,7 @@ export type TreeViewProps<T> = {
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<T extends TreeItem>({
render,
icon,
isError,
isVisible,
selectedItem,
onAccepted,
onSelected,
@ -61,13 +63,43 @@ export function TreeView<T extends TreeItem>({
autoExpandDepth,
}: TreeViewProps<T>) {
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<T>(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<TreeItem, boolean>();
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 <TreeListView
items={[...treeItems.keys()]}
items={visibleItems}
id={item => item.id}
dataTestId={dataTestId}
render={item => {

View File

@ -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<string>();
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<FrameLocator> {
await this.selectAction(actionName, ordinal);

View File

@ -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');
});

View File

@ -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 }) => {