diff --git a/packages/isomorphic/trace/traceModel.ts b/packages/isomorphic/trace/traceModel.ts index 459088a7e8305..eef67a59a6cec 100644 --- a/packages/isomorphic/trace/traceModel.ts +++ b/packages/isomorphic/trace/traceModel.ts @@ -38,7 +38,11 @@ export type SourceModel = { content: string | undefined; }; -export type ResourceEntry = ResourceSnapshot & { id: string, contextTitle: string }; +export type ResourceEntry = ResourceSnapshot & { id: string }; + +export function resourceOwnerRef(resource: ResourceSnapshot): string | undefined { + return resource.pageref ?? resource._serviceWorkerRef ?? resource._apiRequestRef; +} export type ActionTreeItem = { id: string; @@ -84,7 +88,7 @@ export class TraceModel { readonly traceUri: string; readonly testTimeout?: number; readonly annotations?: trace.TraceEventAnnotation[]; - readonly pagerefToTitle = new Map(); + readonly resourceOwnerRefToTitle = new Map(); private _eventsForAction = new Map(); private _screenshots = new Map(); private _ariaSnapshots = new Map(); @@ -116,12 +120,9 @@ export class TraceModel { this.hasSource = contexts.some(c => c.hasSource); this.hasStepData = contexts.some(context => context.origin === 'testRunner'); this.resources = []; - let lastApiContextId = 0; - let lastBrowserContextId = 0; - for (const context of contexts) { - const contextTitle = context.resources.some(resource => resource._apiRequest) ? 'api#' + (++lastApiContextId) : 'browser#' + (++lastBrowserContextId); - for (const entry of context.resources) - this.resources.push({ ...entry, id: `${entry.pageref ?? lastApiContextId}-${entry.startedDateTime}-${entry.request.url}`, contextTitle }); + for (let i = 0; i < contexts.length; ++i) { + for (const entry of contexts[i].resources) + this.resources.push({ ...entry, id: `${resourceOwnerRef(entry) ?? i}-${entry.startedDateTime}-${entry.request.url}` }); } for (const context of contexts) { for (const event of context.screenshots || []) @@ -133,10 +134,19 @@ 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))); + this.pages.forEach((page, index) => this.resourceOwnerRefToTitle.set(page.pageId, 'page#' + (index + 1))); this.events.sort((a1, a2) => a1.time - a2.time); this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!); + + let serviceWorkerCount = 0; + let apiRequestCount = 0; + for (const resource of this.resources) { + if (resource._serviceWorkerRef && !this.resourceOwnerRefToTitle.has(resource._serviceWorkerRef)) + this.resourceOwnerRefToTitle.set(resource._serviceWorkerRef, `service-worker#${++serviceWorkerCount}`); + if (resource._apiRequestRef && !this.resourceOwnerRefToTitle.has(resource._apiRequestRef)) + this.resourceOwnerRefToTitle.set(resource._apiRequestRef, `api#${++apiRequestCount}`); + } this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions(); this.sources = collectSources(this.actions, this.errorDescriptors); diff --git a/packages/isomorphic/trace/traceModernizer.ts b/packages/isomorphic/trace/traceModernizer.ts index f883aaa37f635..20047ddb4d73b 100644 --- a/packages/isomorphic/trace/traceModernizer.ts +++ b/packages/isomorphic/trace/traceModernizer.ts @@ -33,7 +33,11 @@ export class TraceVersionError extends Error { // 6 => 10/2023 ~1.40 // 7 => 05/2024 ~1.45 -const latestVersion: trace.VERSION = 8; +// 9 => 08/2026 ~1.63 +const latestVersion: trace.VERSION = 9; + +// Ensures distinct api request refs across contexts of the same trace. +let lastApiRequestRefOrdinal = 0; export class TraceModernizer { private _contextEntry: ContextEntry; @@ -43,6 +47,7 @@ export class TraceModernizer { private _pageEntries = new Map(); private _jsHandles = new Map(); private _consoleObjects = new Map(); + private _apiRequestRef: string | undefined; constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) { this._contextEntry = contextEntry; @@ -492,4 +497,21 @@ export class TraceModernizer { } return result; } + + _modernize_8_to_9(events: traceV8.TraceEvent[]): trace.TraceEvent[] { + for (const event of events) { + if (event.type !== 'resource-snapshot') + continue; + const snapshot = event.snapshot; + // Older traces marked api requests with a boolean instead of referencing + // their api request context. + if ((snapshot as any)._apiRequest) { + if (!this._apiRequestRef) + this._apiRequestRef = 'api-request-context@' + (++lastApiRequestRefOrdinal); + snapshot._apiRequestRef = this._apiRequestRef; + delete (snapshot as any)._apiRequest; + } + } + return events as trace.TraceEvent[]; + } } diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index f24b39d6cd126..930746ea08d03 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -212,7 +212,7 @@ export class HarTracer { if (!this._shouldIncludeEntryWithUrl(event.url.toString())) return; const harEntry = createHarEntry(undefined, event.method, event.url, undefined, this._options); - harEntry._apiRequest = true; + harEntry._apiRequestRef = this._context.guid; if (!this._options.omitCookies) harEntry.request.cookies = event.cookies; harEntry.request.headers = Object.entries(event.headers).map(([name, value]) => ({ name, value })); @@ -285,6 +285,9 @@ export class HarTracer { const pageEntry = this._createPageEntryIfNeeded(page); const harEntry = createHarEntry(pageEntry?.id, request.method(), url, request.frame()?.guid, this._options, request.wallTimeMs()); + const serviceWorker = request.serviceWorker(); + if (serviceWorker) + harEntry._serviceWorkerRef = serviceWorker.guid; harEntry._resourceType = request.resourceType(); this._recordRequestHeadersAndCookies(harEntry, request.headers()); harEntry.request.postData = this._postDataForRequest(request, this._options.content); diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index c8732eb513d47..b296ba9a242c2 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -56,7 +56,7 @@ import type { Progress } from '../../progress'; import type * as types from '../../types'; import type { Screencast, ScreencastClient } from '../../screencast'; -const version: trace.VERSION = 8; +const version: trace.VERSION = 9; export type TracerOptions = { name?: string; diff --git a/packages/playwright-core/src/tools/trace/traceRequests.ts b/packages/playwright-core/src/tools/trace/traceRequests.ts index c2a21e82ca309..beb7d92cf1300 100644 --- a/packages/playwright-core/src/tools/trace/traceRequests.ts +++ b/packages/playwright-core/src/tools/trace/traceRequests.ts @@ -183,14 +183,14 @@ function bytesToString(bytes: number): string { return gb.toFixed(1) + 'G'; } -function formatRouteStatus(r: { _wasAborted?: boolean, _wasContinued?: boolean, _wasFulfilled?: boolean, _apiRequest?: boolean }): string { +function formatRouteStatus(r: { _wasAborted?: boolean, _wasContinued?: boolean, _wasFulfilled?: boolean, _apiRequestRef?: string }): string { if (r._wasAborted) return 'aborted'; if (r._wasContinued) return 'continued'; if (r._wasFulfilled) return 'fulfilled'; - if (r._apiRequest) + if (r._apiRequestRef) return 'api'; return ''; } diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 1daa10ae1ccf5..962e945b7b6e2 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -34,7 +34,7 @@ import type EventEmitter from 'events'; export type Attachment = TestInfo['attachments'][0]; export const testTraceEntryName = 'test.trace'; -const version: trace.VERSION = 8; +const version: trace.VERSION = 9; let traceOrdinal = 0; type TraceFixtureValue = PlaywrightWorkerOptions['trace'] | undefined; diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index e8ec8df517b39..1c0a2bfabda2e 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -65,7 +65,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 pageTitle = (id: string | undefined) => (id && model.resourceOwnerRefToTitle.get(id)) || ''; const entries: ConsoleEntry[] = []; function addEntry(entry: Omit) { const lastEntry = entries[entries.length - 1]; diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index d7b4d1a0781be..12899215b23a4 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -20,6 +20,7 @@ import './networkTab.css'; import { NetworkResourceDetails, WebSocketResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@isomorphic/formatUtils'; import { PlaceholderPanel } from './placeholderPanel'; +import { resourceOwnerRef } 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'; @@ -216,13 +217,10 @@ const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell }; function resourceContextId(model: TraceModel | undefined, resource: ResourceEntry): string { - if (!model) + const ownerRef = resourceOwnerRef(resource); + if (!model || !ownerRef) return ''; - if (resource.pageref) - return model.pagerefToTitle.get(resource.pageref) || ''; - if (resource._apiRequest) - return resource.contextTitle; - return ''; + return model.resourceOwnerRefToTitle.get(ownerRef) || ''; } const renderEntry = (resource: ResourceEntry, boundaries: Boundaries, model: TraceModel | undefined): RenderedEntry => { @@ -275,7 +273,7 @@ function formatRouteStatus(request: ResourceEntry): string { return 'continued'; if (request._wasFulfilled) return 'fulfilled'; - if (request._apiRequest) + if (request._apiRequestRef) return 'api'; return ''; } diff --git a/packages/trace/src/har.ts b/packages/trace/src/har.ts index 3711f033d55a2..054f3d29489ed 100644 --- a/packages/trace/src/har.ts +++ b/packages/trace/src/har.ts @@ -71,7 +71,8 @@ export type Entry = { _wasAborted?: boolean; _wasFulfilled?: boolean; _wasContinued?: boolean; - _apiRequest?: boolean; + _serviceWorkerRef?: string; + _apiRequestRef?: string; _resourceType?: string; _webSocketMessages?: WebSocketMessage[]; }; diff --git a/packages/trace/src/trace.ts b/packages/trace/src/trace.ts index e5317b8b0825b..a0ef448183be5 100644 --- a/packages/trace/src/trace.ts +++ b/packages/trace/src/trace.ts @@ -68,7 +68,7 @@ export type SerializedError = { }; // Make sure you add _modernize_N_to_N1(event: any) to traceModernizer.ts. -export type VERSION = 8; +export type VERSION = 9; export type BrowserContextEventOptions = { baseURL?: string, diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index fcc9323add279..d9a7a02f15f8d 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -518,6 +518,21 @@ test('should have network requests', async ({ showTraceViewer }) => { await expect(traceViewer.networkRequests.filter({ hasText: '404GET404text' })).toHaveCSS('background-color', 'rgb(242, 222, 222)'); }); +test('should attribute network requests to service workers', async ({ runAndTrace, page, context, server, browserName }) => { + test.skip(browserName !== 'chromium', 'Service worker requests are only reported in Chromium'); + const traceViewer = await runAndTrace(async () => { + const [worker] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html'), + ]); + await page.evaluate(() => window['activationPromise']); + await worker.evaluate(() => fetch('/one-style.css')); + }); + await traceViewer.showNetworkTab(); + await expect(traceViewer.networkRequests.filter({ hasText: 'sw.html' })).toContainText(['page#1']); + await expect(traceViewer.networkRequests.filter({ hasText: 'one-style.css' })).toContainText(['service-worker#1']); +}); + test('should highlight network request on timeline on hover', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer(traceFile); await traceViewer.selectAction('Navigate'); diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index 0f9a165b38170..16e2ae51d8d69 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -224,7 +224,7 @@ test('should record context API request trace independently', async ({ context, const browserTrace = await parseTraceRaw(browserTracePath); expect(browserTrace.actions).toContain('Navigate to "/one-style.html"'); expect(browserTrace.actions).not.toContain('POST "/simple.json"'); - expect(browserTrace.events.some(event => event.type === 'resource-snapshot' && event.snapshot._apiRequest)).toBe(false); + expect(browserTrace.events.some(event => event.type === 'resource-snapshot' && event.snapshot.request.url.endsWith('/simple.json'))).toBe(false); expect(browserTrace.events.some(event => event.type === 'resource-snapshot' && event.snapshot.request.url.endsWith('/one-style.html'))).toBe(true); const apiTrace = await parseTraceRaw(apiTracePath); @@ -233,6 +233,7 @@ test('should record context API request trace independently', async ({ context, const apiAction = apiTrace.actionObjects.find(action => action.class === 'APIRequestContext' && action.method === 'fetch')!; expect(relativeStack(apiAction, apiTrace.stacks)).toEqual(['tracing.spec.ts']); expect(apiTrace.events.filter(event => event.type === 'resource-snapshot').map(event => event.snapshot.request.url)).toEqual([apiURL]); + expect(apiTrace.events.filter(event => event.type === 'resource-snapshot').map(event => event.snapshot._apiRequestRef)).toEqual([expect.stringMatching(/^request-context@/)]); }); test('should collect two traces', async ({ context, page, server }, testInfo) => { diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index fea0550e8d64f..b7847e7834f24 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -238,7 +238,7 @@ test('should not mixup network files between contexts', async ({ runInlineTest, expect(apiURLsByTrace.every(urls => urls.length <= 1)).toBe(true); expect(apiURLsByTrace.flat().sort()).toEqual(apiURLs); const trace = await parseTrace(tracePath); - expect(trace.model.resources.filter(resource => resource._apiRequest).map(resource => resource.request.url).sort()).toEqual(apiURLs); + expect(trace.model.resources.filter(resource => resource._apiRequestRef).map(resource => resource.request.url).sort()).toEqual(apiURLs); }); test('should save sources when requested', async ({ runInlineTest }, testInfo) => {