From 2734a0534256ffde6bd8dc8d27581c7dd26fe2a6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 15 May 2024 16:29:26 -0700 Subject: [PATCH] feat(trace-viewer): show nework request source id (#30810) image Fixes https://github.com/microsoft/playwright/issues/28903 --- packages/trace-viewer/src/ui/modelUtil.ts | 4 +- packages/trace-viewer/src/ui/networkTab.tsx | 92 +++++++++++++++++++-- tests/playwright-test/ui-mode-trace.spec.ts | 21 +++++ 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 6385f577c8..ba866ad7ca 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 b3caa501b5..d007d58198 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -21,12 +21,14 @@ 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'; +import type { ContextEntry } from '../entries'; type NetworkTabModel = { resources: Entry[], + contextIdMap: ContextIdMap, }; type RenderedEntry = { @@ -39,6 +41,7 @@ type RenderedEntry = { start: number, route: string, resource: Entry, + contextId: string, }; type ColumnName = keyof RenderedEntry; type Sorting = { by: ColumnName, negate: boolean}; @@ -54,7 +57,8 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT }); return filtered; }, [model, selectedTime]); - return { resources }; + const contextIdMap = React.useMemo(() => new ContextIdMap(model), [model]); + return { resources, contextIdMap }; } export const NetworkTab: React.FunctionComponent<{ @@ -66,11 +70,11 @@ export const NetworkTab: React.FunctionComponent<{ const [selectedEntry, setSelectedEntry] = React.useState(undefined); const { renderedEntries } = React.useMemo(() => { - const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries)); + const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)); if (sorting) sort(renderedEntries, sorting); return { renderedEntries }; - }, [networkModel.resources, sorting, boundaries]); + }, [networkModel.resources, networkModel.contextIdMap, sorting, boundaries]); if (!networkModel.resources.length) return ; @@ -81,7 +85,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 +104,8 @@ export const NetworkTab: React.FunctionComponent<{ }; const columnTitle = (column: ColumnName) => { + if (column === 'contextId') + return 'Source'; if (column === 'name') return 'Name'; if (column === 'method') @@ -128,10 +134,28 @@ const columnWidth = (column: ColumnName) => { return 60; if (column === 'contentType') return 200; + if (column === 'contextId') + return 60; 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 +183,57 @@ const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell return { body: '' }; }; -const renderEntry = (resource: Entry, boundaries: Boundaries): RenderedEntry => { +class ContextIdMap { + private _pagerefToShortId = new Map(); + private _contextToId = new Map(); + private _lastPageId = 0; + private _lastApiRequestContextId = 0; + + constructor(model: MultiTraceModel | undefined) {} + + 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 = this._contextToId.get(contextEntry); + if (!contextId) { + ++this._lastApiRequestContextId; + contextId = 'api#' + this._lastApiRequestContextId; + this._contextToId.set(contextEntry, 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: ContextIdMap): RenderedEntry => { const routeStatus = formatRouteStatus(resource); let resourceName: string; try { @@ -184,7 +258,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 +324,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/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 194e89afba..0f6194b25f 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(); +});