From 32d5e82312c82b5a4948909f59045b380569110e Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 17 Jun 2026 13:21:28 +0100 Subject: [PATCH] feat(trace-viewer): show message source in the console panel Adds a per-entry source badge to the Console panel, mirroring the Network panel's "Source" column: page#N for browser messages from multiple pages, "page" for a single page, and "test" for runner (stdout/stderr) messages. The page/api id assignment is unified onto TraceModel so both panels label the same source identically. Fixes: https://github.com/microsoft/playwright/issues/41320 --- packages/isomorphic/trace/traceModel.ts | 13 ++- packages/trace-viewer/src/ui/consoleTab.css | 51 ++++-------- packages/trace-viewer/src/ui/consoleTab.tsx | 19 +++-- packages/trace-viewer/src/ui/networkTab.tsx | 83 +++++-------------- tests/library/trace-viewer.spec.ts | 26 ++++-- .../ui-mode-test-output.spec.ts | 12 +-- tests/playwright-test/ui-mode-trace.spec.ts | 11 +++ 7 files changed, 103 insertions(+), 112 deletions(-) diff --git a/packages/isomorphic/trace/traceModel.ts b/packages/isomorphic/trace/traceModel.ts index ea8381f910c26..359fa05ea769b 100644 --- a/packages/isomorphic/trace/traceModel.ts +++ b/packages/isomorphic/trace/traceModel.ts @@ -90,7 +90,8 @@ export class TraceModel { readonly actionCounters: Map; readonly traceUri: string; readonly testTimeout?: number; - + readonly pagerefToTitle = new Map(); + readonly contextToTitle = new Map(); constructor(traceUri: string, contexts: ContextEntry[]) { contexts.forEach(contextEntry => indexModel(contextEntry)); @@ -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(); diff --git a/packages/trace-viewer/src/ui/consoleTab.css b/packages/trace-viewer/src/ui/consoleTab.css index 391b181beee8d..0ea7bf75e9cf5 100644 --- a/packages/trace-viewer/src/ui/consoleTab.css +++ b/packages/trace-viewer/src/ui/consoleTab.css @@ -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; @@ -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; @@ -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; diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index 16d5809d2ba91..91f8622377b0f 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -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'; @@ -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; @@ -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) { const lastEntry = entries[entries.length - 1]; @@ -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++; @@ -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, }); } @@ -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, }); } @@ -128,6 +133,7 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime: nodeMessage: { html }, isError: event.type === 'stderr', isWarning: false, + pageId: '', timestamp: event.timestamp, }); } @@ -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<{ @@ -165,8 +173,9 @@ export const ConsoleTab: React.FunctionComponent<{ render={entry => { const timestamp = msToString(entry.timestamp - boundaries.minimum); const timestampElement = {timestamp}; - const errorSuffix = entry.isError ? 'status-error' : entry.isWarning ? 'status-warning' : 'status-none'; - const statusElement = entry.browserMessage || entry.browserError ? : ; + const isBrowserMessage = !!(entry.browserMessage || entry.browserError); + const source = !isBrowserMessage ? 'test' : (consoleModel.hasMultiplePages ? (entry.pageId || 'page') : 'page'); + const sourceElement = {source}; let locationText: string | undefined; let messageBody: React.JSX.Element[] | string | undefined; let messageInnerHTML: string | undefined; @@ -193,7 +202,7 @@ export const ConsoleTab: React.FunctionComponent<{ return
{timestampElement} - {statusElement} + {sourceElement} {locationText && {locationText}} {entry.repeat > 1 && {entry.repeat}} {messageBody && {messageBody}} diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 411b67fceeb93..954cc83cb3605 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -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 = { @@ -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<{ @@ -75,12 +73,13 @@ export const NetworkTab: React.FunctionComponent<{ const [selectedResourceKey, setSelectedResourceKey] = React.useState(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]); @@ -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} @@ -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; } @@ -217,57 +216,19 @@ const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell return { body: '' }; }; -class ContextIdMap { - private _pagerefToShortId = new Map(); - private _contextToId = new Map(); - 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(); - 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 { @@ -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), }; }; diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index e8bc8e1d359c3..4f0668b1b8182 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -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'); @@ -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(); diff --git a/tests/playwright-test/ui-mode-test-output.spec.ts b/tests/playwright-test/ui-mode-test-output.spec.ts index e6e548b7e2792..e0d12e63409dd 100644 --- a/tests/playwright-test/ui-mode-test-output.spec.ts +++ b/tests/playwright-test/ui-mode-test-output.spec.ts @@ -103,12 +103,12 @@ test('should show console messages for test', async ({ runUITest }, testInfo) => 'Colors: RED GREEN', ]); - await expect(page.locator('.console-tab .list-view-entry .codicon')).toHaveClass([ - 'codicon codicon-browser status-none', - 'codicon codicon-file status-none', - 'codicon codicon-browser status-error', - 'codicon codicon-file status-error', - 'codicon codicon-file status-none', + await expect(page.locator('.console-tab .console-source')).toHaveText([ + 'page', + 'test', + 'page', + 'test', + 'test', ]); await expect.soft(page.getByText('RED', { exact: true })).toHaveCSS('color', 'rgb(205, 49, 49)'); diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index eb0643bcee376..b337fba3b44ff 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -420,6 +420,10 @@ test('should show request source context id', async ({ runUITest, server }) => { const page2 = await context.newPage(); await page2.goto('${server.EMPTY_PAGE}'); await request.get('${server.EMPTY_PAGE}'); + console.log('log from node'); + console.error('error from node'); + await page.evaluate(() => console.log('here from page1')); + await page2.evaluate(() => console.log('here from page2')); }); `, }); @@ -432,6 +436,13 @@ test('should show request source context id', async ({ runUITest, server }) => { await expect(page.getByText('page#1')).toBeVisible(); await expect(page.getByText('page#2')).toBeVisible(); await expect(page.getByText('api#1')).toBeVisible(); + + await page.getByText('Console', { exact: true }).click(); + const consoleLines = page.getByRole('tabpanel', { name: 'Console' }).getByRole('option'); + await expect(consoleLines.filter({ hasText: 'log from node' }).locator('.console-source')).toHaveText('test'); + await expect(consoleLines.filter({ hasText: 'error from node' }).locator('.console-source')).toHaveText('test'); + await expect(consoleLines.filter({ hasText: 'here from page1' }).locator('.console-source')).toHaveText('page#1'); + await expect(consoleLines.filter({ hasText: 'here from page2' }).locator('.console-source')).toHaveText('page#2'); }); test('should work behind reverse proxy', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33705' } }, async ({ runUITest, proxyServer: reverseProxy }) => {