mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: filter actions, console and network based on the timeline window (#26509)
This commit is contained in:
parent
0149c7d56c
commit
a705d68c8a
@ -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>
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -564,7 +564,6 @@ const TraceView: React.FC<{
|
||||
return <Workbench
|
||||
key='workbench'
|
||||
model={model?.model}
|
||||
hideTimelineBars={true}
|
||||
hideStackFrames={true}
|
||||
showSourcesFirst={true}
|
||||
rootDir={rootDir}
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user