diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts index afa2e7d63e6e6..91cfeb14bff52 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts @@ -42,6 +42,8 @@ export type SourceModel = { content: string | undefined; }; +export type ResourceEntry = ResourceSnapshot & { id: string }; + export type ActionTraceEventInContext = ActionEntry & { context: ContextEntry; }; @@ -84,7 +86,7 @@ export class TraceModel { readonly sdkLanguage: Language | undefined; readonly testIdAttributeName: string | undefined; readonly sources: Map; - resources: ResourceSnapshot[]; + resources: ResourceEntry[]; readonly actionCounters: Map; readonly traceUri: string; @@ -113,7 +115,7 @@ export class TraceModel { this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors)); this.hasSource = contexts.some(c => c.hasSource); this.hasStepData = contexts.some(context => context.origin === 'testRunner'); - this.resources = [...contexts.map(c => c.resources)].flat(); + this.resources = [...contexts.map(c => c.resources)].flat().map(entry => ({ ...entry, id: `${entry.pageref}-${entry.time}-${entry.request.url}` })); this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []); this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_')); diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index e014401cb0220..c182b46736054 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -14,14 +14,13 @@ * limitations under the License. */ -import type { Entry } from '@trace/har'; import * as React from 'react'; import type { Boundaries } from './geometry'; import './networkTab.css'; import { NetworkResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@web/uiUtils'; import { PlaceholderPanel } from './placeholderPanel'; -import { context } from '@isomorphic/trace/traceModel'; +import { context, type ResourceEntry } from '@isomorphic/trace/traceModel'; import type { TraceModel } from '@isomorphic/trace/traceModel'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; @@ -30,7 +29,7 @@ import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType import type { Language } from '@isomorphic/locatorGenerators'; type NetworkTabModel = { - resources: Entry[], + resources: ResourceEntry[], contextIdMap: ContextIdMap, }; @@ -44,7 +43,7 @@ type RenderedEntry = { size: number, start: number, route: string, - resource: Entry, + resource: ResourceEntry, contextId: string, }; type ColumnName = keyof RenderedEntry; @@ -72,11 +71,9 @@ export const NetworkTab: React.FunctionComponent<{ sdkLanguage: Language, }> = ({ boundaries, networkModel, onResourceHovered, sdkLanguage }) => { const [sorting, setSorting] = React.useState(undefined); - const [selectedEntry, setSelectedEntry] = React.useState(undefined); + const [selectedResourceKey, setSelectedResourceKey] = React.useState(undefined); const [filterState, setFilterState] = React.useState(defaultFilterState); - const visibleSelectedEntry = React.useMemo(() => (selectedEntry && networkModel.resources.includes(selectedEntry.resource)) ? selectedEntry : undefined, [selectedEntry, networkModel.resources]); - const { renderedEntries } = React.useMemo(() => { const renderedEntries = networkModel.resources.map((entry, i) => renderEntry(entry, boundaries, networkModel.contextIdMap, i)).filter(filterEntry(filterState)); if (sorting) @@ -84,13 +81,15 @@ export const NetworkTab: React.FunctionComponent<{ return { renderedEntries }; }, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]); + const visibleSelectedEntry = React.useMemo(() => (selectedResourceKey ? renderedEntries.find(entry => entry.resource.id === selectedResourceKey) : undefined), [selectedResourceKey, renderedEntries]); + const [columnWidths, setColumnWidths] = React.useState>(() => { return new Map(allColumns().map(column => [column, columnWidth(column)])); }); const onFilterStateChange = React.useCallback((newFilterState: FilterState) => { setFilterState(newFilterState); - setSelectedEntry(undefined); + setSelectedResourceKey(undefined); }, []); if (!networkModel.resources.length) @@ -101,7 +100,7 @@ export const NetworkTab: React.FunctionComponent<{ ariaLabel='Network requests' items={renderedEntries} selectedItem={visibleSelectedEntry} - onSelected={item => setSelectedEntry(item)} + onSelected={item => setSelectedResourceKey(item.resource.id)} onHighlighted={item => onResourceHovered?.(item?.ordinal)} columns={visibleColumns(!!visibleSelectedEntry, renderedEntries)} columnTitle={columnTitle} @@ -122,7 +121,7 @@ export const NetworkTab: React.FunctionComponent<{ sidebarIsFirst={true} orientation='horizontal' settingName='networkResourceDetails' - main={ setSelectedEntry(undefined)} />} + main={ setSelectedResourceKey(undefined)} />} sidebar={grid} />} ; @@ -223,7 +222,7 @@ class ContextIdMap { constructor(model: TraceModel | undefined) {} - contextId(resource: Entry): string { + contextId(resource: ResourceEntry): string { if (resource.pageref) return this._pageId(resource.pageref); else if (resource._apiRequest) @@ -241,7 +240,7 @@ class ContextIdMap { return shortId; } - private _apiRequestContextId(resource: Entry): string { + private _apiRequestContextId(resource: ResourceEntry): string { const contextEntry = context(resource); if (!contextEntry) return ''; @@ -265,7 +264,7 @@ function hasMultipleContexts(renderedEntries: RenderedEntry[]): boolean { return false; } -const renderEntry = (resource: Entry, boundaries: Boundaries, contextIdGenerator: ContextIdMap, ordinal: number): RenderedEntry => { +const renderEntry = (resource: ResourceEntry, boundaries: Boundaries, contextIdGenerator: ContextIdMap, ordinal: number): RenderedEntry => { const routeStatus = formatRouteStatus(resource); let resourceName: string; try { @@ -298,7 +297,7 @@ const renderEntry = (resource: Entry, boundaries: Boundaries, contextIdGenerator }; }; -function formatRouteStatus(request: Entry): string { +function formatRouteStatus(request: ResourceEntry): string { if (request._wasAborted) return 'aborted'; if (request._wasContinued) diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index 79fa8387e5757..968a91e5c0036 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -374,7 +374,6 @@ test('should not preserve selection across test runs', async ({ runUITest, serve import { test, expect } from '@playwright/test'; test('network tab test', async ({ page }) => { await page.goto('${server.PREFIX}/network-tab/network.html'); - await page.evaluate(() => (window as any).donePromise); }); `, }); @@ -383,11 +382,35 @@ test('should not preserve selection across test runs', async ({ runUITest, serve await expect(page.getByTestId('workbench-run-status')).toContainText('Passed'); await page.getByRole('tab', { name: 'Network' }).click(); - const networkItem = page.getByRole('listitem').filter({ hasText: 'network.html' }); - await networkItem.click(); + await page.getByRole('listitem').filter({ hasText: 'network.html' }).click(); const headersPanel = page.getByRole('tabpanel', { name: 'Headers' }); await expect(headersPanel).toBeVisible(); await page.getByRole('treeitem', { name: 'network tab test' }).dblclick(); await expect(headersPanel).toBeHidden(); + await expect(page.getByTestId('workbench-run-status')).toContainText('Passed'); + await expect(headersPanel).toBeHidden(); +}); + +test('should preserve selection during test run', async ({ runUITest, server }, testInfo) => { + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + // Keep test running to make sure that selected network entry stay open + await page.waitForTimeout(${testInfo.timeout}); + }); + `, + }); + + await page.getByRole('treeitem', { name: 'network tab test' }).dblclick(); + await page.getByRole('tab', { name: 'Network' }).click(); + await page.getByRole('listitem').filter({ hasText: 'network.html' }).click(); + const headersPanel = page.getByRole('tabpanel', { name: 'Headers' }); + await expect(headersPanel).toBeVisible(); + + // Wait to ensure that trace polling (every 500ms) does not close the selected entry + await page.waitForTimeout(1000); + await expect(headersPanel).toBeVisible(); });