Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/isomorphic/trace/traceModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ export class TraceModel {
readonly actionCounters: Map<string, number>;
readonly traceUri: string;
readonly testTimeout?: number;

readonly pagerefToTitle = new Map<string, string>();
readonly contextToTitle = new Map<ContextEntry, string>();

constructor(traceUri: string, contexts: ContextEntry[]) {
contexts.forEach(contextEntry => indexModel(contextEntry));
Expand Down Expand Up @@ -121,6 +122,16 @@ export class TraceModel {
this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []);
this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_'));

this.pages.forEach((page, index) => this.pagerefToTitle.set(page.pageId, 'page#' + (index + 1)));
let lastApiContextId = 0;
let lastBrowserContextId = 0;
for (const context of contexts) {
if (context.resources.some(resource => resource._apiRequest))
this.contextToTitle.set(context, 'api#' + (++lastApiContextId));
else
this.contextToTitle.set(context, 'browser#' + (++lastBrowserContextId));
}

this.events.sort((a1, a2) => a1.time - a2.time);
this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!);
this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions();
Expand Down
51 changes: 18 additions & 33 deletions packages/trace-viewer/src/ui/consoleTab.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,6 @@
user-select: text;
}

.console-line .codicon {
padding: 0 2px 0 3px;
position: relative;
flex: none;
top: 3px;
}

.console-line.warning .codicon {
color: darkorange;
}

.console-line-message {
word-break: break-word;
white-space: pre-wrap;
Expand All @@ -50,6 +39,24 @@
user-select: none;
}

.console-source {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: 48px;
height: 16px;
padding: 0 4px;
margin: 0 6px 0 3px;
font-size: 11px;
border-radius: 4px;
background-color: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
user-select: none;
flex: none;
vertical-align: middle;
}

.console-time {
float: left;
min-width: 50px;
Expand All @@ -62,28 +69,6 @@
margin-left: 50px;
}

.console-line .codicon.status-none::after,
.console-line .codicon.status-error::after,
.console-line .codicon.status-warning::after {
display: inline-block;
content: 'a';
color: transparent;
border-radius: 4px;
width: 8px;
height: 8px;
position: relative;
top: 8px;
left: -7px;
}

.console-line .codicon.status-error::after {
background-color: var(--vscode-errorForeground);
}

.console-line .codicon.status-warning::after {
background-color: var(--vscode-list-warningForeground);
}

.console-repeat {
display: inline-block;
padding: 0 2px;
Expand Down
19 changes: 14 additions & 5 deletions packages/trace-viewer/src/ui/consoleTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import './consoleTab.css';
import type { TraceModel } from '@isomorphic/trace/traceModel';
import { ListView } from '@web/components/listView';
import type { Boundaries } from './geometry';
import { clsx } from '@web/uiUtils';
import { msToString } from '@isomorphic/formatUtils';
import { ansi2html } from '@web/ansi2html';
import { PlaceholderPanel } from './placeholderPanel';
Expand All @@ -38,11 +37,13 @@ export type ConsoleEntry = {
isError: boolean;
isWarning: boolean;
timestamp: number;
pageId: string;
repeat: number;
};

type ConsoleTabModel = {
entries: ConsoleEntry[],
hasMultiplePages: boolean,
};

const ConsoleListView = ListView<ConsoleEntry>;
Expand All @@ -63,6 +64,7 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime:
const { entries } = React.useMemo(() => {
if (!model)
return { entries: [] };
const pageTitle = (id: string | undefined) => (id && model.pagerefToTitle.get(id)) || '';
const entries: ConsoleEntry[] = [];
function addEntry(entry: Omit<ConsoleEntry, 'repeat'>) {
const lastEntry = entries[entries.length - 1];
Expand All @@ -74,6 +76,7 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime:
&& entry.nodeMessage?.html === lastEntry.nodeMessage?.html
&& entry.isError === lastEntry.isError
&& entry.isWarning === lastEntry.isWarning
&& entry.pageId === lastEntry.pageId
&& entry.timestamp - lastEntry.timestamp < 1000;
if (isSameAsLast)
lastEntry.repeat++;
Expand Down Expand Up @@ -103,6 +106,7 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime:
},
isError: event.messageType === 'error',
isWarning: event.messageType === 'warning',
pageId: pageTitle(event.pageId),
timestamp: event.time,
});
}
Expand All @@ -113,6 +117,7 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime:
browserError: event.params.error,
isError: true,
isWarning: false,
pageId: pageTitle(event.pageId),
timestamp: event.time,
});
}
Expand All @@ -128,6 +133,7 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime:
nodeMessage: { html },
isError: event.type === 'stderr',
isWarning: false,
pageId: '',
timestamp: event.timestamp,
});
}
Expand All @@ -141,7 +147,9 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime:
return entries.filter(entry => entry.timestamp >= selectedTime.minimum && entry.timestamp <= selectedTime.maximum);
}, [entries, selectedTime]);

return { entries: filteredEntries };
const hasMultiplePages = React.useMemo(() => new Set(filteredEntries.map(entry => entry.pageId).filter(Boolean)).size > 1, [filteredEntries]);

return { entries: filteredEntries, hasMultiplePages };
}

export const ConsoleTab: React.FunctionComponent<{
Expand All @@ -165,8 +173,9 @@ export const ConsoleTab: React.FunctionComponent<{
render={entry => {
const timestamp = msToString(entry.timestamp - boundaries.minimum);
const timestampElement = <span className='console-time'>{timestamp}</span>;
const errorSuffix = entry.isError ? 'status-error' : entry.isWarning ? 'status-warning' : 'status-none';
const statusElement = entry.browserMessage || entry.browserError ? <span className={clsx('codicon', 'codicon-browser', errorSuffix)} title='Browser message'></span> : <span className={clsx('codicon', 'codicon-file', errorSuffix)} title='Runner message'></span>;
const isBrowserMessage = !!(entry.browserMessage || entry.browserError);
const source = !isBrowserMessage ? 'test' : (consoleModel.hasMultiplePages ? (entry.pageId || 'page') : 'page');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we hide 'page' label by default to avoid the ui noise? I don’t think it’s useful in tests that only have one page.

const sourceElement = <span className='console-source' title={isBrowserMessage ? 'Browser message' : 'Runner message'}>{source}</span>;
let locationText: string | undefined;
let messageBody: React.JSX.Element[] | string | undefined;
let messageInnerHTML: string | undefined;
Expand All @@ -193,7 +202,7 @@ export const ConsoleTab: React.FunctionComponent<{

return <div className='console-line'>
{timestampElement}
{statusElement}
{sourceElement}
{locationText && <span className='console-location'>{locationText}</span>}
{entry.repeat > 1 && <span className='console-repeat'>{entry.repeat}</span>}
{messageBody && <span className='console-line-message'>{messageBody}</span>}
Expand Down
83 changes: 22 additions & 61 deletions packages/trace-viewer/src/ui/networkTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,16 @@ import './networkTab.css';
import { NetworkResourceDetails, WebSocketResourceDetails } from './networkResourceDetails';
import { bytesToString, msToString } from '@isomorphic/formatUtils';
import { PlaceholderPanel } from './placeholderPanel';
import { context, type ResourceEntry } from '@isomorphic/trace/traceModel';
import type { TraceModel } from '@isomorphic/trace/traceModel';
import { context } from '@isomorphic/trace/traceModel';
import type { ResourceEntry, TraceModel } from '@isomorphic/trace/traceModel';
import { GridView, type RenderedGridCell } from '@web/components/gridView';
import { SplitView } from '@web/components/splitView';
import type { ContextEntry } from '@isomorphic/trace/entries';
import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType } from './networkFilters';
import type { Language } from '@isomorphic/locatorGenerators';

type NetworkTabModel = {
resources: ResourceEntry[],
contextIdMap: ContextIdMap,
model: TraceModel | undefined,
};

type RenderedEntry = {
Expand Down Expand Up @@ -61,8 +60,7 @@ export function useNetworkTabModel(model: TraceModel | undefined, selectedTime:
});
return filtered;
}, [model, selectedTime, pageId]);
const contextIdMap = React.useMemo(() => new ContextIdMap(model), [model]);
return { resources, contextIdMap };
return { resources, model };
}

export const NetworkTab: React.FunctionComponent<{
Expand All @@ -75,12 +73,13 @@ export const NetworkTab: React.FunctionComponent<{
const [selectedResourceKey, setSelectedResourceKey] = React.useState<string | undefined>(undefined);
const [filterState, setFilterState] = React.useState(defaultFilterState);

const { renderedEntries } = React.useMemo(() => {
const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)).filter(filterEntry(filterState));
const { renderedEntries, multipleContexts } = React.useMemo(() => {
const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.model)).filter(filterEntry(filterState));
if (sorting)
sort(renderedEntries, sorting);
return { renderedEntries };
}, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]);
const multipleContexts = new Set(renderedEntries.map(entry => entry.contextId).filter(Boolean)).size > 1;
return { renderedEntries, multipleContexts };
}, [networkModel.resources, networkModel.model, filterState, sorting, boundaries]);

const visibleSelectedEntry = React.useMemo(() => (selectedResourceKey ? renderedEntries.find(entry => entry.resource.id === selectedResourceKey) : undefined), [selectedResourceKey, renderedEntries]);

Expand All @@ -103,7 +102,7 @@ export const NetworkTab: React.FunctionComponent<{
selectedItem={visibleSelectedEntry}
onSelected={item => setSelectedResourceKey(item.resource.id)}
onHighlighted={item => onResourceHovered?.(item ? resourceTimeRange(item.resource) : undefined)}
columns={visibleColumns(!!visibleSelectedEntry, renderedEntries)}
columns={visibleColumns(!!visibleSelectedEntry, multipleContexts)}
columnTitle={columnTitle}
columnWidths={columnWidths}
setColumnWidths={setColumnWidths}
Expand Down Expand Up @@ -166,15 +165,15 @@ const columnWidth = (column: ColumnName) => {
return 100;
};

function visibleColumns(entrySelected: boolean, renderedEntries: RenderedEntry[]): (keyof RenderedEntry)[] {
function visibleColumns(entrySelected: boolean, multipleContexts: boolean): (keyof RenderedEntry)[] {
if (entrySelected) {
const columns: (keyof RenderedEntry)[] = ['name'];
if (hasMultipleContexts(renderedEntries))
if (multipleContexts)
columns.unshift('contextId');
return columns;
}
let columns: (keyof RenderedEntry)[] = allColumns();
if (!hasMultipleContexts(renderedEntries))
if (!multipleContexts)
columns = columns.filter(name => name !== 'contextId');
return columns;
}
Expand Down Expand Up @@ -217,57 +216,19 @@ const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell
return { body: '' };
};

class ContextIdMap {
private _pagerefToShortId = new Map<string, string>();
private _contextToId = new Map<ContextEntry, string>();
private _lastPageId = 0;
private _lastApiRequestContextId = 0;

constructor(model: TraceModel | undefined) {}

contextId(resource: ResourceEntry): string {
if (resource.pageref)
return this._pageId(resource.pageref);
else if (resource._apiRequest)
return this._apiRequestContextId(resource);
function resourceContextId(model: TraceModel | undefined, resource: ResourceEntry): string {
if (!model)
return '';
}

private _pageId(pageref: string): string {
let shortId = this._pagerefToShortId.get(pageref);
if (!shortId) {
++this._lastPageId;
shortId = 'page#' + this._lastPageId;
this._pagerefToShortId.set(pageref, shortId);
}
return shortId;
}

private _apiRequestContextId(resource: ResourceEntry): string {
if (resource.pageref)
return model.pagerefToTitle.get(resource.pageref) || '';
if (resource._apiRequest) {
const contextEntry = context(resource);
if (!contextEntry)
return '';
let contextId = this._contextToId.get(contextEntry);
if (!contextId) {
++this._lastApiRequestContextId;
contextId = 'api#' + this._lastApiRequestContextId;
this._contextToId.set(contextEntry, contextId);
}
return contextId;
return (contextEntry && model.contextToTitle.get(contextEntry)) || '';
}
return '';
}

function hasMultipleContexts(renderedEntries: RenderedEntry[]): boolean {
const contextIds = new Set<string>();
for (const entry of renderedEntries) {
contextIds.add(entry.contextId);
if (contextIds.size > 1)
return true;
}
return false;
}

const renderEntry = (resource: ResourceEntry, boundaries: Boundaries, contextIdGenerator: ContextIdMap): RenderedEntry => {
const renderEntry = (resource: ResourceEntry, boundaries: Boundaries, model: TraceModel | undefined): RenderedEntry => {
const routeStatus = formatRouteStatus(resource);
let resourceName: string;
try {
Expand Down Expand Up @@ -300,7 +261,7 @@ const renderEntry = (resource: ResourceEntry, boundaries: Boundaries, contextIdG
start: resource._monotonicTime! - boundaries.minimum,
route: routeStatus,
resource,
contextId: contextIdGenerator.contextId(resource),
contextId: resourceContextId(model, resource),
};
};

Expand Down
26 changes: 20 additions & 6 deletions tests/library/trace-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,13 +351,13 @@ test('should render console', async ({ showTraceViewer, browserName }) => {
// Browsers can insert more messages between these two.
await expect(traceViewer.consoleLineMessages.filter({ hasText: 'Cheers!' })).toBeVisible();

const icons = traceViewer.consoleLines.locator('.codicon');
await expect.soft(icons.nth(0)).toHaveClass('codicon codicon-browser status-none');
await expect.soft(icons.nth(1)).toHaveClass('codicon codicon-browser status-warning');
await expect.soft(icons.nth(2)).toHaveClass('codicon codicon-browser status-error');
await expect.soft(icons.nth(3)).toHaveClass('codicon codicon-browser status-error');
const sources = traceViewer.consoleLines.locator('.console-source');
await expect.soft(sources.nth(0)).toHaveText('page');
await expect.soft(sources.nth(1)).toHaveText('page');
await expect.soft(sources.nth(2)).toHaveText('page');
await expect.soft(sources.nth(3)).toHaveText('page');
// Browsers can insert more messages between these two.
await expect.soft(traceViewer.consoleLines.filter({ hasText: 'Cheers!' }).locator('.codicon')).toHaveClass('codicon codicon-browser status-none');
await expect.soft(traceViewer.consoleLines.filter({ hasText: 'Cheers!' }).locator('.console-source')).toHaveText('page');
await expect(traceViewer.consoleStacks.first()).toContainText('Error: Unhandled exception');

await traceViewer.selectAction('Evaluate');
Expand All @@ -371,6 +371,20 @@ test('should render console', async ({ showTraceViewer, browserName }) => {
await expect(listViews.filter({ hasText: 'Cheers!' })).toHaveClass('list-view-entry');
});

test('should show console source for multiple pages', async ({ context, runAndTrace, server }) => {
const traceViewer = await runAndTrace(async () => {
const page1 = await context.newPage();
await page1.goto(server.EMPTY_PAGE);
await page1.evaluate(() => console.log('hello from one'));
const page2 = await context.newPage();
await page2.goto(server.EMPTY_PAGE);
await page2.evaluate(() => console.log('hello from two'));
});
await traceViewer.showConsoleTab();
await expect(traceViewer.consoleLines.filter({ hasText: 'hello from one' }).locator('.console-source')).toHaveText('page#1');
await expect(traceViewer.consoleLines.filter({ hasText: 'hello from two' }).locator('.console-source')).toHaveText('page#2');
});

test('should highlight console message on timeline on hover', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer(traceFile);
await traceViewer.showConsoleTab();
Expand Down
Loading
Loading