diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 6385f577c8ed8..ba866ad7caf29 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -157,6 +157,8 @@ function indexModel(context: ContextEntry) { } for (const event of context.events) (event as any)[contextSymbol] = context; + for (const resource of context.resources) + (resource as any)[contextSymbol] = context; } function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) { @@ -330,7 +332,7 @@ export function idForAction(action: ActionTraceEvent) { return `${action.pageId || 'none'}:${action.callId}`; } -export function context(action: ActionTraceEvent | trace.EventTraceEvent): ContextEntry { +export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { return (action as any)[contextSymbol]; } diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index b3caa501b5768..7172a5eabdbea 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -21,7 +21,7 @@ import './networkTab.css'; import { NetworkResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@web/uiUtils'; import { PlaceholderPanel } from './placeholderPanel'; -import type { MultiTraceModel } from './modelUtil'; +import { context, type MultiTraceModel } from './modelUtil'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; @@ -39,6 +39,7 @@ type RenderedEntry = { start: number, route: string, resource: Entry, + contextId: string, }; type ColumnName = keyof RenderedEntry; type Sorting = { by: ColumnName, negate: boolean}; @@ -60,13 +61,15 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT export const NetworkTab: React.FunctionComponent<{ boundaries: Boundaries, networkModel: NetworkTabModel, + model?: MultiTraceModel, onEntryHovered: (entry: Entry | undefined) => void, -}> = ({ boundaries, networkModel, onEntryHovered }) => { +}> = ({ boundaries, networkModel, model, onEntryHovered }) => { const [sorting, setSorting] = React.useState(undefined); const [selectedEntry, setSelectedEntry] = React.useState(undefined); const { renderedEntries } = React.useMemo(() => { - const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries)); + const generator = ContextIdGenerator.forModel(model); + const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, generator)); if (sorting) sort(renderedEntries, sorting); return { renderedEntries }; @@ -81,7 +84,7 @@ export const NetworkTab: React.FunctionComponent<{ selectedItem={selectedEntry} onSelected={item => setSelectedEntry(item)} onHighlighted={item => onEntryHovered(item?.resource)} - columns={selectedEntry ? ['name'] : ['name', 'method', 'status', 'contentType', 'duration', 'size', 'start', 'route']} + columns={visibleColumns(!!selectedEntry, renderedEntries)} columnTitle={columnTitle} columnWidth={columnWidth} isError={item => item.status.code >= 400} @@ -100,6 +103,8 @@ export const NetworkTab: React.FunctionComponent<{ }; const columnTitle = (column: ColumnName) => { + if (column === 'contextId') + return 'Source'; if (column === 'name') return 'Name'; if (column === 'method') @@ -131,7 +136,23 @@ const columnWidth = (column: ColumnName) => { return 100; }; +function visibleColumns(entrySelected: boolean, renderedEntries: RenderedEntry[]): (keyof RenderedEntry)[] { + if (entrySelected) + return ['name']; + const columns: (keyof RenderedEntry)[] = []; + if (hasMultipleContexts(renderedEntries)) + columns.push('contextId'); + columns.push('name', 'method', 'status', 'contentType', 'duration', 'size', 'start', 'route'); + return columns; +} + const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell => { + if (column === 'contextId') { + return { + body: entry.contextId, + title: entry.name.url, + }; + } if (column === 'name') { return { body: entry.name.name, @@ -159,7 +180,67 @@ const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell return { body: '' }; }; -const renderEntry = (resource: Entry, boundaries: Boundaries): RenderedEntry => { +class ContextIdGenerator { + private _pagerefToShortId = new Map(); + private _lastPageId = 0; + private _lastApiRequestContextId = 0; + private static _apiRequestContextIdSymbol = Symbol('apiRequestContextId'); + private static _contextIdGeneratorSymbol = Symbol('contextIdGenerator'); + + static forModel(model?: MultiTraceModel): ContextIdGenerator { + if (!model) + return new ContextIdGenerator(); + let generator = (model as any)[ContextIdGenerator._contextIdGeneratorSymbol]; + if (!generator) { + generator = new ContextIdGenerator(); + (model as any)[ContextIdGenerator._contextIdGeneratorSymbol] = generator; + } + return generator; + } + + contextId(resource: Entry): string { + if (resource.pageref) + return this._pageId(resource.pageref); + else if (resource._apiRequest) + return this._apiRequestContextId(resource); + 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: Entry): string { + const contextEntry = context(resource); + if (!contextEntry) + return ''; + let contextId = (contextEntry as any)[ContextIdGenerator._apiRequestContextIdSymbol]; + if (!contextId) { + ++this._lastApiRequestContextId; + contextId = 'api@' + this._lastApiRequestContextId; + (contextEntry as any)[ContextIdGenerator._apiRequestContextIdSymbol] = contextId; + } + return contextId; + } +} + +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: Entry, boundaries: Boundaries, contextIdGenerator: ContextIdGenerator): RenderedEntry => { const routeStatus = formatRouteStatus(resource); let resourceName: string; try { @@ -184,7 +265,8 @@ const renderEntry = (resource: Entry, boundaries: Boundaries): RenderedEntry => size: resource.response._transferSize! > 0 ? resource.response._transferSize! : resource.response.bodySize, start: resource._monotonicTime! - boundaries.minimum, route: routeStatus, - resource + resource, + contextId: contextIdGenerator.contextId(resource), }; }; @@ -249,4 +331,7 @@ function comparator(sortBy: ColumnName) { return a.route.localeCompare(b.route); }; } + + if (sortBy === 'contextId') + return (a: RenderedEntry, b: RenderedEntry) => a.contextId.localeCompare(b.contextId); } diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index c50b345b75f22..17c63ad8704f3 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -172,7 +172,7 @@ export const Workbench: React.FunctionComponent<{ id: 'network', title: 'Network', count: networkModel.resources.length, - render: () => + render: () => }; const attachmentsTab: TabbedPaneTabModel = { id: 'attachments', diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 194e89afba598..c9afe336e6c66 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -285,3 +285,24 @@ test('should reveal errors in the sourcetab', async ({ runUITest }) => { await page.getByText('a.spec.ts:4', { exact: true }).click(); await expect(page.locator('.source-line-running')).toContainText(`throw new Error('Oh my');`); }); + +test('should show request source context id', async ({ runUITest, server }) => { + const { page } = await runUITest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ page, context, request }) => { + await page.goto('${server.EMPTY_PAGE}'); + const page2 = await context.newPage(); + await page2.goto('${server.EMPTY_PAGE}'); + await request.get('${server.EMPTY_PAGE}'); + }); + `, + }); + + await page.getByText('pass').dblclick(); + await page.getByText('Network', { exact: true }).click(); + await expect(page.locator('span').filter({ hasText: 'Source' })).toBeVisible(); + await expect(page.getByText('page@1')).toBeVisible(); + await expect(page.getByText('page@2')).toBeVisible(); + await expect(page.getByText('api@1')).toBeVisible(); +});