feat(trace-viewer): show nework request source id (#30810)

<img width="1392" alt="image"
src="https://github.com/microsoft/playwright/assets/9798949/dcfd4d71-4a41-48ac-9f24-2996200f966a">

Fixes https://github.com/microsoft/playwright/issues/28903
This commit is contained in:
Yury Semikhatsky 2024-05-15 16:29:26 -07:00 committed by GitHub
parent 89cdf3d56e
commit 2734a05342
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 109 additions and 8 deletions

View File

@ -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];
}

View File

@ -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<RenderedEntry | undefined>(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 <PlaceholderPanel text='No network calls' />;
@ -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<string, string>();
private _contextToId = new Map<ContextEntry, string>();
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<string>();
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);
}

View File

@ -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();
});