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 type { TreeState } from '@web/components/treeView';
import { TreeView } from '@web/components/treeView'; import { TreeView } from '@web/components/treeView';
import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil'; import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil';
import type { Boundaries } from '../geometry';
export interface ActionListProps { export interface ActionListProps {
actions: ActionTraceEventInContext[], actions: ActionTraceEventInContext[],
selectedAction: ActionTraceEventInContext | undefined, selectedAction: ActionTraceEventInContext | undefined,
selectedTime: Boundaries | undefined,
sdkLanguage: Language | undefined; sdkLanguage: Language | undefined;
onSelected: (action: ActionTraceEventInContext) => void, onSelected: (action: ActionTraceEventInContext) => void,
onHighlighted: (action: ActionTraceEventInContext | undefined) => void, onHighlighted: (action: ActionTraceEventInContext | undefined) => void,
@ -40,6 +42,7 @@ const ActionTreeView = TreeView<ActionTreeItem>;
export const ActionList: React.FC<ActionListProps> = ({ export const ActionList: React.FC<ActionListProps> = ({
actions, actions,
selectedAction, selectedAction,
selectedTime,
sdkLanguage, sdkLanguage,
onSelected, onSelected,
onHighlighted, onHighlighted,
@ -63,15 +66,16 @@ export const ActionList: React.FC<ActionListProps> = ({
onSelected={item => onSelected(item.action!)} onSelected={item => onSelected(item.action!)}
onHighlighted={item => onHighlighted(item?.action)} onHighlighted={item => onHighlighted(item?.action)}
isError={item => !!item.action?.error?.message} 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)} render={item => renderAction(item.action!, sdkLanguage, revealConsole, isLive || false)}
/>; />;
}; };
const renderAction = ( export const renderAction = (
action: ActionTraceEvent, action: ActionTraceEvent,
sdkLanguage: Language | undefined, sdkLanguage?: Language,
revealConsole: () => void, revealConsole?: () => void,
isLive: boolean, isLive?: boolean,
) => { ) => {
const { errors, warnings } = modelUtil.stats(action); const { errors, warnings } = modelUtil.stats(action);
const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector, false /* isFrameLocator */, true /* playSafe */) : undefined; 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>} {action.method === 'goto' && action.params.url && <div className='action-url' title={action.params.url}>{action.params.url}</div>}
</div> </div>
<div className='action-duration' style={{ flex: 'none' }}>{time || <span className='codicon codicon-loading'></span>}</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>} {!!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>} {!!warnings && <div className='action-icon'><span className='codicon codicon-warning'></span><span className="action-icon-value">{warnings}</span></div>}
</div> </div>

View File

@ -15,12 +15,12 @@
*/ */
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import type { ActionTraceEvent } from '@trace/trace';
import * as React from 'react'; import * as React from 'react';
import './consoleTab.css'; import './consoleTab.css';
import * as modelUtil from './modelUtil'; import * as modelUtil from './modelUtil';
import { ListView } from '@web/components/listView'; import { ListView } from '@web/components/listView';
import { ansi2htmlMarkup } from '@web/components/errorMessage'; import { ansi2htmlMarkup } from '@web/components/errorMessage';
import type { Boundaries } from '../geometry';
type ConsoleEntry = { type ConsoleEntry = {
message?: channels.ConsoleMessageInitializer; message?: channels.ConsoleMessageInitializer;
@ -31,20 +31,18 @@ type ConsoleEntry = {
isError: boolean; isError: boolean;
}, },
timestamp: number; timestamp: number;
highlight: boolean;
}; };
const ConsoleListView = ListView<ConsoleEntry>; const ConsoleListView = ListView<ConsoleEntry>;
export const ConsoleTab: React.FunctionComponent<{ export const ConsoleTab: React.FunctionComponent<{
model: modelUtil.MultiTraceModel | undefined, model: modelUtil.MultiTraceModel | undefined,
action: ActionTraceEvent | undefined, selectedTime: Boundaries | undefined,
}> = ({ model, action }) => { }> = ({ model, selectedTime }) => {
const { entries } = React.useMemo(() => { const { entries } = React.useMemo(() => {
if (!model) if (!model)
return { entries: [] }; return { entries: [] };
const entries: ConsoleEntry[] = []; const entries: ConsoleEntry[] = [];
const actionEvents = action ? modelUtil.eventsForAction(action) : [];
for (const event of model.events) { for (const event of model.events) {
if (event.method !== 'console' && event.method !== 'pageError') if (event.method !== 'console' && event.method !== 'pageError')
continue; continue;
@ -52,14 +50,12 @@ export const ConsoleTab: React.FunctionComponent<{
const { guid } = event.params.message; const { guid } = event.params.message;
entries.push({ entries.push({
message: modelUtil.context(event).initializers[guid], message: modelUtil.context(event).initializers[guid],
highlight: actionEvents.includes(event),
timestamp: event.time, timestamp: event.time,
}); });
} }
if (event.method === 'pageError') { if (event.method === 'pageError') {
entries.push({ entries.push({
error: event.params.error, error: event.params.error,
highlight: actionEvents.includes(event),
timestamp: event.time, timestamp: event.time,
}); });
} }
@ -72,16 +68,21 @@ export const ConsoleTab: React.FunctionComponent<{
isError: event.type === 'stderr', isError: event.type === 'stderr',
}, },
timestamp: event.timestamp, timestamp: event.timestamp,
highlight: false,
}); });
} }
entries.sort((a, b) => a.timestamp - b.timestamp); entries.sort((a, b) => a.timestamp - b.timestamp);
return { entries }; 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'> return <div className='console-tab'>
<ConsoleListView <ConsoleListView
items={entries} items={filteredEntries}
isError={entry => !!entry.error || entry.message?.type === 'error' || entry.nodeMessage?.isError || false} isError={entry => !!entry.error || entry.message?.type === 'error' || entry.nodeMessage?.isError || false}
isWarning={entry => entry.message?.type === 'warning'} isWarning={entry => entry.message?.type === 'warning'}
render={entry => { render={entry => {
@ -122,9 +123,7 @@ export const ConsoleTab: React.FunctionComponent<{
</div>; </div>;
} }
return null; return null;
} }}
}
isHighlighted={entry => !!entry.highlight}
/> />
</div>; </div>;
}; };

View File

@ -45,11 +45,15 @@
.film-strip-hover { .film-strip-hover {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0;
bottom: 0;
left: 0; left: 0;
background-color: white; 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; 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; z-index: 200;
pointer-events: none; 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 * as React from 'react';
import { useMeasure, upperBound } from '@web/uiUtils'; import { useMeasure, upperBound } from '@web/uiUtils';
import type { PageEntry } from '../entries'; 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 tileSize = { width: 200, height: 45 };
const frameMargin = 2.5; const frameMargin = 2.5;
@ -28,7 +37,7 @@ const rowHeight = tileSize.height + frameMargin * 2;
export const FilmStrip: React.FunctionComponent<{ export const FilmStrip: React.FunctionComponent<{
model?: MultiTraceModel, model?: MultiTraceModel,
boundaries: Boundaries, boundaries: Boundaries,
previewPoint?: { x: number, clientY: number }, previewPoint?: FilmStripPreviewPoint,
}> = ({ model, boundaries, previewPoint }) => { }> = ({ model, boundaries, previewPoint }) => {
const [measure, ref] = useMeasure<HTMLDivElement>(); const [measure, ref] = useMeasure<HTMLDivElement>();
const lanesRef = React.useRef<HTMLDivElement>(null); const lanesRef = React.useRef<HTMLDivElement>(null);
@ -45,7 +54,11 @@ export const FilmStrip: React.FunctionComponent<{
if (previewPoint !== undefined && screencastFrames) { if (previewPoint !== undefined && screencastFrames) {
const previewTime = boundaries.minimum + (boundaries.maximum - boundaries.minimum) * previewPoint.x / measure.width; const previewTime = boundaries.minimum + (boundaries.maximum - boundaries.minimum) * previewPoint.x / measure.width;
previewImage = screencastFrames[upperBound(screencastFrames, previewTime, timeComparator) - 1]; 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}> return <div className='film-strip' ref={ref}>
@ -59,12 +72,13 @@ export const FilmStrip: React.FunctionComponent<{
}</div> }</div>
{previewImage && previewSize && previewPoint?.x !== undefined && {previewImage && previewSize && previewPoint?.x !== undefined &&
<div className='film-strip-hover' style={{ <div className='film-strip-hover' style={{
width: previewSize.width,
height: previewSize.height,
top: measure.bottom + 5, top: measure.bottom + 5,
left: Math.min(previewPoint!.x, measure.width - previewSize.width - 10), 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>
} }
</div>; </div>;

View File

@ -24,7 +24,6 @@ const contextSymbol = Symbol('context');
const nextInContextSymbol = Symbol('next'); const nextInContextSymbol = Symbol('next');
const prevInListSymbol = Symbol('prev'); const prevInListSymbol = Symbol('prev');
const eventsSymbol = Symbol('events'); const eventsSymbol = Symbol('events');
const resourcesSymbol = Symbol('resources');
export type SourceLocation = { export type SourceLocation = {
file: string; file: string;
@ -87,6 +86,7 @@ export class MultiTraceModel {
this.resources = [...contexts.map(c => c.resources)].flat(); this.resources = [...contexts.map(c => c.resources)].flat();
this.events.sort((a1, a2) => a1.time - a2.time); this.events.sort((a1, a2) => a1.time - a2.time);
this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!);
this.sources = collectSources(this.actions); this.sources = collectSources(this.actions);
} }
} }
@ -239,19 +239,6 @@ export function eventsForAction(action: ActionTraceEvent): EventTraceEvent[] {
return result; 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> { function collectSources(actions: trace.ActionTraceEvent[]): Map<string, SourceModel> {
const result = new Map<string, SourceModel>(); const result = new Map<string, SourceModel>();
for (const action of actions) { for (const action of actions) {

View File

@ -30,10 +30,6 @@
flex: 1; flex: 1;
} }
.network-request.highlighted {
background-color: var(--vscode-list-inactiveSelectionBackground);
}
.network-request-title-status { .network-request-title-status {
padding: 0 2px; padding: 0 2px;
border-radius: 4px; border-radius: 4px;

View File

@ -22,8 +22,7 @@ import type { Entry } from '@trace/har';
export const NetworkResourceDetails: React.FunctionComponent<{ export const NetworkResourceDetails: React.FunctionComponent<{
resource: ResourceSnapshot, resource: ResourceSnapshot,
highlighted: boolean, }> = ({ resource }) => {
}> = ({ resource, highlighted }) => {
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const [requestBody, setRequestBody] = React.useState<string | null>(null); const [requestBody, setRequestBody] = React.useState<string | null>(null);
const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: 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]); }, [contentType, resource, resourceName, routeStatus]);
return <div return <div
className={'network-request' + (highlighted ? ' highlighted' : '')}> className='network-request'>
<Expandable expanded={expanded} setExpanded={setExpanded} title={ renderTitle() }> <Expandable expanded={expanded} setExpanded={setExpanded} title={ renderTitle() }>
<div className='network-request-details'> <div className='network-request-details'>
<div className='network-request-details-time'>{resource.time}ms</div> <div className='network-request-details-time'>{resource.time}ms</div>

View File

@ -15,23 +15,28 @@
*/ */
import * as React from 'react'; import * as React from 'react';
import type { ActionTraceEvent } from '@trace/trace'; import type * as modelUtil from './modelUtil';
import * as modelUtil from './modelUtil';
import { NetworkResourceDetails } from './networkResourceDetails'; import { NetworkResourceDetails } from './networkResourceDetails';
import './networkTab.css'; import './networkTab.css';
import type { Boundaries } from '../geometry';
export const NetworkTab: React.FunctionComponent<{ export const NetworkTab: React.FunctionComponent<{
model: modelUtil.MultiTraceModel | undefined, model: modelUtil.MultiTraceModel | undefined,
action: ActionTraceEvent | undefined, selectedTime: Boundaries | undefined,
}> = ({ model, action }) => { }> = ({ model, selectedTime }) => {
const actionResources = action ? modelUtil.resourcesForAction(action) : []; const resources = React.useMemo(() => {
const resources = model?.resources || []; const resources = model?.resources || [];
return <div className='network-tab'>{ 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) => { resources.map((resource, index) => {
return <NetworkResourceDetails return <NetworkResourceDetails
resource={resource} resource={resource}
key={index} key={index}
highlighted={actionResources.includes(resource)}
/>; />;
}) })
}</div>; }</div>;

View File

@ -25,6 +25,10 @@
margin-left: 10px; margin-left: 10px;
} }
.timeline-view.dragging {
cursor: ew-resize;
}
.timeline-divider { .timeline-divider {
position: absolute; position: absolute;
width: 1px; width: 1px;
@ -42,16 +46,16 @@
pointer-events: none; pointer-events: none;
} }
.timeline-time-input {
cursor: pointer;
}
.timeline-lane { .timeline-lane {
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
flex: none; flex: none;
height: 30px;
position: relative;
}
.timeline-lane.timeline-labels {
height: 20px; height: 20px;
position: relative;
} }
.timeline-grid { .timeline-grid {
@ -64,72 +68,26 @@
.timeline-lane.timeline-bars { .timeline-lane.timeline-bars {
pointer-events: auto; pointer-events: auto;
margin-bottom: 10px; margin-bottom: 5px;
height: 5px;
overflow: visible; overflow: visible;
} }
.timeline-bar { .timeline-bar {
position: absolute; position: absolute;
height: 9px; height: 2px;
border-radius: 2px;
min-width: 3px;
--action-color: gray; --action-color: gray;
background-color: var(--action-color); background-color: var(--action-color);
} }
.timeline-bar.selected { .timeline-bar.action {
filter: brightness(70%); --action-color: var(--vscode-charts-red);
box-shadow: 0 0 0 1px var(--action-color);
} }
.timeline-bar.frame_click, .timeline-bar.network {
.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 {
--action-color: var(--vscode-charts-blue); --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 { .timeline-label {
position: absolute; position: absolute;
top: 0; top: 0;
@ -150,11 +108,46 @@
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 3px; pointer-events: none;
background-color: black; border-left: 3px solid var(--light-pink);
}
.timeline-window {
display: flex;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none; pointer-events: none;
} }
.timeline-marker.timeline-marker-hover { .timeline-window-center {
background-color: var(--light-pink); 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. limitations under the License.
*/ */
import type { EventTraceEvent } from '@trace/trace';
import { msToString, useMeasure } from '@web/uiUtils'; import { msToString, useMeasure } from '@web/uiUtils';
import * as React from 'react'; import * as React from 'react';
import type { Boundaries } from '../geometry'; import type { Boundaries } from '../geometry';
import { FilmStrip } from './filmStrip'; import { FilmStrip } from './filmStrip';
import type { FilmStripPreviewPoint } from './filmStrip';
import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil';
import './timeline.css'; import './timeline.css';
import { asLocator } from '@isomorphic/locatorGenerators';
import type { Language } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators';
import type { Entry } from '@trace/har';
type TimelineBar = { type TimelineBar = {
action?: ActionTraceEventInContext; action?: ActionTraceEventInContext;
event?: EventTraceEvent; resource?: Entry;
leftPosition: number; leftPosition: number;
rightPosition: number; rightPosition: number;
leftTime: number; leftTime: number;
rightTime: number; rightTime: number;
type: string;
label: string;
title: string | undefined;
className: string;
}; };
export const Timeline: React.FunctionComponent<{ export const Timeline: React.FunctionComponent<{
model: MultiTraceModel | undefined, model: MultiTraceModel | undefined,
selectedAction: ActionTraceEventInContext | undefined, boundaries: Boundaries,
onSelected: (action: ActionTraceEventInContext) => void, onSelected: (action: ActionTraceEventInContext) => void,
hideTimelineBars?: boolean, selectedTime: Boundaries | undefined,
setSelectedTime: (time: Boundaries | undefined) => void,
sdkLanguage: Language, sdkLanguage: Language,
}> = ({ model, selectedAction, onSelected, hideTimelineBars, sdkLanguage }) => { }> = ({ model, boundaries, onSelected, selectedTime, setSelectedTime, sdkLanguage }) => {
const [measure, ref] = useMeasure<HTMLDivElement>(); 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 { offsets, curtainLeft, curtainRight } = React.useMemo(() => {
const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>(); let activeWindow = selectedTime || boundaries;
if (dragWindow && dragWindow.startX !== dragWindow.endX) {
const { boundaries, offsets } = React.useMemo(() => { const time1 = positionToTime(measure.width, boundaries, dragWindow.startX);
const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 }; const time2 = positionToTime(measure.width, boundaries, dragWindow.endX);
if (boundaries.minimum > boundaries.maximum) { activeWindow = { minimum: Math.min(time1, time2), maximum: Math.max(time1, time2) };
boundaries.minimum = 0;
boundaries.maximum = 30000;
} }
// Leave some nice free space on the right hand side. const curtainLeft = timeToPosition(measure.width, boundaries, activeWindow.minimum);
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; const maxRight = timeToPosition(measure.width, boundaries, boundaries.maximum);
return { boundaries, offsets: calculateDividerOffsets(measure.width, boundaries) }; const curtainRight = maxRight - timeToPosition(measure.width, boundaries, activeWindow.maximum);
}, [measure.width, model]); return { offsets: calculateDividerOffsets(measure.width, boundaries), curtainLeft, curtainRight };
}, [selectedTime, boundaries, dragWindow, measure]);
const bars = React.useMemo(() => { const bars = React.useMemo(() => {
const bars: TimelineBar[] = []; const bars: TimelineBar[] = [];
for (const entry of model?.actions || []) { for (const entry of model?.actions || []) {
const locator = asLocator(sdkLanguage || 'javascript', entry.params.selector, false /* isFrameLocator */, true /* playSafe */); if (entry.class === 'Test')
let detail = trimRight(locator || '', 50); continue;
if (entry.method === 'goto')
detail = trimRight(entry.params.url || '', 50);
bars.push({ bars.push({
action: entry, action: entry,
leftTime: entry.startTime, leftTime: entry.startTime,
rightTime: entry.endTime, rightTime: entry.endTime || boundaries.maximum,
leftPosition: timeToPosition(measure.width, boundaries, entry.startTime), leftPosition: timeToPosition(measure.width, boundaries, entry.startTime),
rightPosition: timeToPosition(measure.width, boundaries, entry.endTime), rightPosition: timeToPosition(measure.width, boundaries, entry.endTime || boundaries.maximum),
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()
}); });
} }
for (const event of model?.events || []) { for (const resource of model?.resources || []) {
const startTime = event.time; const startTime = resource._monotonicTime!;
const endTime = resource._monotonicTime! + resource.time;
bars.push({ bars.push({
event, resource,
leftTime: startTime, leftTime: startTime,
rightTime: startTime, rightTime: endTime,
leftPosition: timeToPosition(measure.width, boundaries, startTime), leftPosition: timeToPosition(measure.width, boundaries, startTime),
rightPosition: timeToPosition(measure.width, boundaries, startTime), rightPosition: timeToPosition(measure.width, boundaries, endTime),
label: event.method,
title: undefined,
type: event.class + '.' + event.method,
className: `${event.class}_${event.method}`.toLowerCase()
}); });
} }
return bars; return bars;
}, [model, boundaries, measure.width, sdkLanguage]); }, [model, boundaries, measure]);
const hoveredBar = hoveredBarIndex !== undefined ? bars[hoveredBarIndex] : undefined; const onMouseDown = React.useCallback((event: React.MouseEvent) => {
let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === selectedAction); setPreviewPoint(undefined);
targetBar = hoveredBar || targetBar; if (!ref.current)
return;
const findHoveredBarIndex = (x: number) => { const x = event.clientX - ref.current.getBoundingClientRect().left;
const time = positionToTime(measure.width, boundaries, x); const time = positionToTime(measure.width, boundaries, x);
const time1 = positionToTime(measure.width, boundaries, x - 5); const leftX = selectedTime ? timeToPosition(measure.width, boundaries, selectedTime.minimum) : 0;
const time2 = positionToTime(measure.width, boundaries, x + 5); const rightX = selectedTime ? timeToPosition(measure.width, boundaries, selectedTime.maximum) : 0;
let index: number | undefined;
let xDistance: number | undefined; if (selectedTime && Math.abs(x - leftX) < 10) {
for (let i = 0; i < bars.length; i++) { // Resize left.
const bar = bars[i]; setDragWindow({ startX: rightX, endX: x, type: 'resize' });
const left = Math.max(bar.leftTime, time1); } else if (selectedTime && Math.abs(x - rightX) < 10) {
const right = Math.min(bar.rightTime, time2); // Resize right.
const xMiddle = (bar.leftTime + bar.rightTime) / 2; setDragWindow({ startX: leftX, endX: x, type: 'resize' });
const xd = Math.abs(time - xMiddle); } else if (selectedTime && time > selectedTime.minimum && time < selectedTime.maximum && event.clientY - ref.current.getBoundingClientRect().top < 20) {
if (left > right) // Move window.
continue; setDragWindow({ startX: leftX, endX: rightX, pivot: x, type: 'move' });
if (index === undefined || xd < xDistance!) { } else {
index = i; // Create new.
xDistance = xd; 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) => { const onMouseLeave = React.useCallback(() => {
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 = () => {
setPreviewPoint(undefined); setPreviewPoint(undefined);
setHoveredBarIndex(undefined); }, []);
};
const onClick = (event: React.MouseEvent) => { const onDoubleClick = React.useCallback(() => {
setPreviewPoint(undefined); setSelectedTime(undefined);
if (!ref.current) }, [setSelectedTime]);
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);
};
return <div style={{ flex: 'none', borderBottom: '1px solid var(--vscode-panel-border)' }}> 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'>{ <div className='timeline-grid'>{
offsets.map((offset, index) => { offsets.map((offset, index) => {
return <div key={index} className='timeline-divider' style={{ left: offset.position + 'px' }}> return <div key={index} className='timeline-divider' style={{ left: offset.position + 'px' }}>
@ -161,37 +202,32 @@ export const Timeline: React.FunctionComponent<{
</div>; </div>;
}) })
}</div> }</div>
{!hideTimelineBars && <div className='timeline-lane timeline-labels'>{ {<div className='timeline-lane timeline-bars'>{
bars.map((bar, index) => { bars.map((bar, index) => {
return <div key={index} return <div key={index}
className={'timeline-label ' + bar.className + (targetBar === bar ? ' selected' : '')} className={'timeline-bar ' + (bar.action ? 'action ' : '') + (bar.resource ? 'network ' : '')}
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' : '')}
style={{ style={{
left: bar.leftPosition + 'px', left: bar.leftPosition + 'px',
width: Math.max(1, bar.rightPosition - bar.leftPosition) + 'px', width: Math.max(1, bar.rightPosition - bar.leftPosition) + 'px',
top: barTop(bar) + 'px', top: barTop(bar) + 'px',
}} }}
title={bar.title}
></div>; ></div>;
}) })
}</div>} }</div>}
<FilmStrip model={model} boundaries={boundaries} previewPoint={previewPoint} /> <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', display: (previewPoint !== undefined) ? 'block' : 'none',
left: (previewPoint?.x || 0) + 'px', 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>
</div>; </div>;
}; };
@ -234,10 +270,6 @@ function positionToTime(clientWidth: number, boundaries: Boundaries, x: number):
return x / clientWidth * (boundaries.maximum - boundaries.minimum) + boundaries.minimum; 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 { 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 return <Workbench
key='workbench' key='workbench'
model={model?.model} model={model?.model}
hideTimelineBars={true}
hideStackFrames={true} hideStackFrames={true}
showSourcesFirst={true} showSourcesFirst={true}
rootDir={rootDir} rootDir={rootDir}

View File

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

View File

@ -78,10 +78,6 @@ body.dark-mode .drop-target {
height: 100%; height: 100%;
} }
.workbench {
contain: size;
}
.header { .header {
display: flex; display: flex;
background-color: #000; background-color: #000;
@ -92,21 +88,25 @@ body.dark-mode .drop-target {
color: #cccccc; color: #cccccc;
} }
.workbench .header .toolbar-button { .workbench-loader {
contain: size;
}
.workbench-loader .header .toolbar-button {
margin: 12px; margin: 12px;
padding: 8px 4px; padding: 8px 4px;
} }
.workbench .logo { .workbench-loader .logo {
font-size: 20px; font-size: 20px;
margin-left: 16px; margin-left: 16px;
} }
.workbench .product { .workbench-loader .product {
font-weight: 600; font-weight: 600;
margin-left: 16px; margin-left: 16px;
} }
.workbench .header .title { .workbench-loader .header .title {
margin-left: 16px; margin-left: 16px;
} }

View File

@ -18,7 +18,7 @@ import { ToolbarButton } from '@web/components/toolbarButton';
import * as React from 'react'; import * as React from 'react';
import type { ContextEntry } from '../entries'; import type { ContextEntry } from '../entries';
import { MultiTraceModel } from './modelUtil'; import { MultiTraceModel } from './modelUtil';
import './workbench.css'; import './workbenchLoader.css';
import { toggleTheme } from '@web/theme'; import { toggleTheme } from '@web/theme';
import { Workbench } from './workbench'; import { Workbench } from './workbench';
import { connect } from './wsPort'; import { connect } from './wsPort';
@ -137,7 +137,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
})(); })();
}, [isServer, traceURLs, uploadedTraceNames]); }, [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='hbox header'>
<div className='logo'>🎭</div> <div className='logo'>🎭</div>
<div className='product'>Playwright</div> <div className='product'>Playwright</div>

View File

@ -25,7 +25,6 @@ export type ListViewProps<T> = {
indent?: (item: T, index: number) => number | undefined, indent?: (item: T, index: number) => number | undefined,
isError?: (item: T, index: number) => boolean, isError?: (item: T, index: number) => boolean,
isWarning?: (item: T, index: number) => boolean, isWarning?: (item: T, index: number) => boolean,
isHighlighted?: (item: T, index: number) => boolean,
selectedItem?: T, selectedItem?: T,
onAccepted?: (item: T, index: number) => void, onAccepted?: (item: T, index: number) => void,
onSelected?: (item: T, index: number) => void, onSelected?: (item: T, index: number) => void,
@ -44,7 +43,6 @@ export function ListView<T>({
icon, icon,
isError, isError,
isWarning, isWarning,
isHighlighted,
indent, indent,
selectedItem, selectedItem,
onAccepted, onAccepted,
@ -113,7 +111,7 @@ export function ListView<T>({
{noItemsMessage && items.length === 0 && <div className='list-view-empty'>{noItemsMessage}</div>} {noItemsMessage && items.length === 0 && <div className='list-view-empty'>{noItemsMessage}</div>}
{items.map((item, index) => { {items.map((item, index) => {
const selectedSuffix = selectedItem === item ? ' selected' : ''; 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 errorSuffix = isError?.(item, index) ? ' error' : '';
const warningSuffix = isWarning?.(item, index) ? ' warning' : ''; const warningSuffix = isWarning?.(item, index) ? ' warning' : '';
const indentation = indent?.(item, index) || 0; const indentation = indent?.(item, index) || 0;

View File

@ -32,6 +32,7 @@ export type TreeViewProps<T> = {
render: (item: T) => React.ReactNode, render: (item: T) => React.ReactNode,
icon?: (item: T) => string | undefined, icon?: (item: T) => string | undefined,
isError?: (item: T) => boolean, isError?: (item: T) => boolean,
isVisible?: (item: T) => boolean,
selectedItem?: T, selectedItem?: T,
onAccepted?: (item: T) => void, onAccepted?: (item: T) => void,
onSelected?: (item: T) => void, onSelected?: (item: T) => void,
@ -50,6 +51,7 @@ export function TreeView<T extends TreeItem>({
render, render,
icon, icon,
isError, isError,
isVisible,
selectedItem, selectedItem,
onAccepted, onAccepted,
onSelected, onSelected,
@ -61,13 +63,43 @@ export function TreeView<T extends TreeItem>({
autoExpandDepth, autoExpandDepth,
}: TreeViewProps<T>) { }: TreeViewProps<T>) {
const treeItems = React.useMemo(() => { const treeItems = React.useMemo(() => {
// Expand all ancestors of the selected item.
for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent) for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent)
treeState.expandedItems.set(item.id, true); treeState.expandedItems.set(item.id, true);
return flattenTree<T>(rootItem, treeState.expandedItems, autoExpandDepth || 0); return flattenTree<T>(rootItem, treeState.expandedItems, autoExpandDepth || 0);
}, [rootItem, selectedItem, treeState, autoExpandDepth]); }, [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 return <TreeListView
items={[...treeItems.keys()]} items={visibleItems}
id={item => item.id} id={item => item.id}
dataTestId={dataTestId} dataTestId={dataTestId}
render={item => { render={item => {

View File

@ -85,18 +85,6 @@ class TraceViewerPage {
await this.page.click('text="Network"'); 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 @step
async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<FrameLocator> { async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<FrameLocator> {
await this.selectAction(actionName, ordinal); await this.selectAction(actionName, ordinal);

View File

@ -128,10 +128,11 @@ test('should contain action info', async ({ showTraceViewer }) => {
expect(logLines).toContain(' click action done'); expect(logLines).toContain(' click action done');
}); });
test('should render events', async ({ showTraceViewer }) => { test('should render network bars', async ({ page, runAndTrace, server }) => {
const traceViewer = await showTraceViewer([traceFile]); const traceViewer = await runAndTrace(async () => {
const events = await traceViewer.eventBars(); await page.goto(server.EMPTY_PAGE);
expect(events).toContain('browsercontext_console'); });
await expect(traceViewer.page.locator('.timeline-bar.network')).toHaveCount(1);
}); });
test('should render console', async ({ showTraceViewer, browserName }) => { test('should render console', async ({ showTraceViewer, browserName }) => {
@ -157,10 +158,10 @@ test('should render console', async ({ showTraceViewer, browserName }) => {
await traceViewer.selectAction('page.evaluate'); await traceViewer.selectAction('page.evaluate');
const listViews = traceViewer.page.locator('.console-tab').locator('.list-view-entry'); 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(0)).toHaveClass('list-view-entry');
await expect(listViews.nth(1)).toHaveClass('list-view-entry highlighted warning'); await expect(listViews.nth(1)).toHaveClass('list-view-entry warning');
await expect(listViews.nth(2)).toHaveClass('list-view-entry highlighted error'); await expect(listViews.nth(2)).toHaveClass('list-view-entry error');
await expect(listViews.nth(3)).toHaveClass('list-view-entry highlighted error'); await expect(listViews.nth(3)).toHaveClass('list-view-entry error');
// Firefox can insert layout error here. // Firefox can insert layout error here.
await expect(listViews.last()).toHaveClass('list-view-entry'); 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 showReport();
await page.click('text=passes'); await page.click('text=passes');
await page.click('img'); 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 }) => { test('should show multi trace source', async ({ runInlineTest, page, server, showReport }) => {