diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 28399a0b031b8..65ce2c1020237 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -918,3 +918,5 @@ Set to `true` to include IndexedDB in the storage state snapshot. ## property: APIRequestContext.tracing * since: v1.60 - type: <[Tracing]> + +Tracing recorder for requests made through this API request context. diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md index d28ce386ae4eb..e181129ccff5f 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md @@ -320,7 +320,7 @@ To specify the final trace zip file name, you need to pass `path` option to Start recording a HAR (HTTP Archive) of network activity in this context. The HAR file is written to disk when [`method: Tracing.stopHar`] is called, or when the returned [Disposable] is disposed. -Only one HAR recording can be active at a time per [BrowserContext]. +Only one HAR recording can be active at a time per [Tracing] instance. **Usage** diff --git a/packages/isomorphic/trace/entries.ts b/packages/isomorphic/trace/entries.ts index cd902c9f118c2..71e2f2bfd4f5c 100644 --- a/packages/isomorphic/trace/entries.ts +++ b/packages/isomorphic/trace/entries.ts @@ -29,6 +29,7 @@ export type ContextEntry = { platform?: string; playwrightVersion?: string; wallTime: number; + monotonicTime: number; sdkLanguage?: Language; testIdAttributeName?: string; title?: string; @@ -40,7 +41,6 @@ export type ContextEntry = { stdio: trace.StdioTraceEvent[]; errors: trace.ErrorTraceEvent[]; hasSource: boolean; - contextId: string; testTimeout?: number; annotations?: trace.TraceEventAnnotation[]; }; @@ -48,7 +48,7 @@ export type ContextEntry = { export type PageEntry = { pageId: string, screencastFrames: { - sha1: string, + file: string, timestamp: number, frameSwapWallTime?: number, width: number, diff --git a/packages/isomorphic/trace/snapshotRenderer.ts b/packages/isomorphic/trace/snapshotRenderer.ts index 7001dea03f89a..2c9284987015d 100644 --- a/packages/isomorphic/trace/snapshotRenderer.ts +++ b/packages/isomorphic/trace/snapshotRenderer.ts @@ -71,7 +71,7 @@ export class SnapshotRenderer { const closestFrame = (wallTime && this._screencastFrames[0]?.frameSwapWallTime) ? findClosest(this._screencastFrames, frame => frame.frameSwapWallTime!, wallTime) : findClosest(this._screencastFrames, frame => frame.timestamp, timestamp); - return closestFrame?.sha1; + return closestFrame?.file; } render(): RenderedFrameSnapshot { @@ -234,14 +234,14 @@ export class SnapshotRenderer { if (index >= 0 && index < this._snapshots.length) override = this._snapshots[index].resourceOverrides.find(o => o.url === url); } - if (override?.sha1) { + if (override?.file) { result = { ...result, response: { ...result.response, content: { ...result.response.content, - _sha1: override.sha1, + _file: override.file, } }, }; diff --git a/packages/isomorphic/trace/snapshotServer.ts b/packages/isomorphic/trace/snapshotServer.ts index e2703a0060fc3..8149a10f85d78 100644 --- a/packages/isomorphic/trace/snapshotServer.ts +++ b/packages/isomorphic/trace/snapshotServer.ts @@ -21,10 +21,10 @@ import type { ResourceSnapshot } from '@trace/snapshot'; export class SnapshotServer { private _snapshotStorage: SnapshotStorage; - private _resourceLoader: (sha1: string) => Promise; + private _resourceLoader: (file: string) => Promise; private _snapshotIds = new Map(); - constructor(snapshotStorage: SnapshotStorage, resourceLoader: (sha1: string) => Promise) { + constructor(snapshotStorage: SnapshotStorage, resourceLoader: (file: string) => Promise) { this._snapshotStorage = snapshotStorage; this._resourceLoader = resourceLoader; } @@ -41,10 +41,10 @@ export class SnapshotServer { async serveClosestScreenshot(pageOrFrameId: string, searchParams: URLSearchParams): Promise { const snapshot = this._snapshot(pageOrFrameId, searchParams); - const sha1 = snapshot?.closestScreenshot(); - if (!sha1) + const file = snapshot?.closestScreenshot(); + if (!file) return new Response(null, { status: 404 }); - return new Response(await this._resourceLoader(sha1)); + return new Response(await this._resourceLoader(file)); } serveSnapshotInfo(pageOrFrameId: string, searchParams: URLSearchParams): Response { @@ -85,8 +85,8 @@ export class SnapshotServer { if (!resource) return new Response(null, { status: 404 }); - const sha1 = resource.response.content._sha1; - const content = sha1 ? await this._resourceLoader(sha1) || new Blob([]) : new Blob([]); + const file = resource.response.content._file; + const content = file ? await this._resourceLoader(file) || new Blob([]) : new Blob([]); let contentType = resource.response.content.mimeType; const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(contentType); diff --git a/packages/isomorphic/trace/snapshotStorage.ts b/packages/isomorphic/trace/snapshotStorage.ts index 28eb3eedae7a6..d71407146bae3 100644 --- a/packages/isomorphic/trace/snapshotStorage.ts +++ b/packages/isomorphic/trace/snapshotStorage.ts @@ -27,15 +27,15 @@ export class SnapshotStorage { renderers: SnapshotRenderer[], }>(); private _cache = new LRUCache(100_000_000); // 100MB per each trace - private _contextToResources = new Map(); + private _resources: ResourceSnapshot[] = []; private _resourceUrlsWithOverrides = new Set(); - addResource(contextId: string, resource: ResourceSnapshot): void { + addResource(resource: ResourceSnapshot): void { resource.request.url = rewriteURLForCustomProtocol(resource.request.url); - this._ensureResourcesForContext(contextId).push(resource); + this._resources.push(resource); } - addFrameSnapshot(contextId: string, snapshot: FrameSnapshot, screencastFrames: PageEntry['screencastFrames']) { + addFrameSnapshot(snapshot: FrameSnapshot, screencastFrames: PageEntry['screencastFrames']) { for (const override of snapshot.resourceOverrides) override.url = rewriteURLForCustomProtocol(override.url); let frameSnapshots = this._frameSnapshots.get(snapshot.frameId); @@ -49,8 +49,7 @@ export class SnapshotStorage { this._frameSnapshots.set(snapshot.pageId, frameSnapshots); } frameSnapshots.raw.push(snapshot); - const resources = this._ensureResourcesForContext(contextId); - const renderer = new SnapshotRenderer(this._cache, resources, frameSnapshots.raw, screencastFrames, frameSnapshots.raw.length - 1); + const renderer = new SnapshotRenderer(this._cache, this._resources, frameSnapshots.raw, screencastFrames, frameSnapshots.raw.length - 1); frameSnapshots.renderers.push(renderer); return renderer; } @@ -66,8 +65,7 @@ export class SnapshotStorage { finalize() { // Resources are not necessarily sorted in the trace file, so sort them now. - for (const resources of this._contextToResources.values()) - resources.sort((a, b) => (a._monotonicTime || 0) - (b._monotonicTime || 0)); + this._resources.sort((a, b) => (a._monotonicTime || 0) - (b._monotonicTime || 0)); // Resources that have overrides should not be cached, otherwise we might get stale content // while serving snapshots with different override values. for (const frameSnapshots of this._frameSnapshots.values()) { @@ -81,13 +79,4 @@ export class SnapshotStorage { hasResourceOverride(url: string) { return this._resourceUrlsWithOverrides.has(url); } - - private _ensureResourcesForContext(contextId: string): ResourceSnapshot[] { - let resources = this._contextToResources.get(contextId); - if (!resources) { - resources = []; - this._contextToResources.set(contextId, resources); - } - return resources; - } } diff --git a/packages/isomorphic/trace/traceLoader.ts b/packages/isomorphic/trace/traceLoader.ts index 8547a32e142f0..777bfbcc5f5dc 100644 --- a/packages/isomorphic/trace/traceLoader.ts +++ b/packages/isomorphic/trace/traceLoader.ts @@ -48,7 +48,7 @@ export class TraceLoader { const match = entryName.match(/(.+)\.trace$/); if (match && (!prefix || prefix === match[1])) prefixes.push(match[1] || ''); - if (entryName.includes('src@')) + if (entryName.startsWith('src/') || entryName.includes('src@')) hasSource = true; } if (!prefixes.length) @@ -97,10 +97,10 @@ export class TraceLoader { unzipProgress?.(++done, total); for (const resource of contextEntry.resources) { - if (resource.request.postData?._sha1) - this._resourceToContentType.set(resource.request.postData._sha1, stripEncodingFromContentType(resource.request.postData.mimeType)); - if (resource.response.content?._sha1) - this._resourceToContentType.set(resource.response.content._sha1, stripEncodingFromContentType(resource.response.content.mimeType)); + if (resource.request.postData?._file) + this._resourceToContentType.set(resource.request.postData._file, stripEncodingFromContentType(resource.request.postData.mimeType)); + if (resource.response.content?._file) + this._resourceToContentType.set(resource.response.content._file, stripEncodingFromContentType(resource.response.content.mimeType)); } this.contextEntries.push(contextEntry); @@ -113,9 +113,9 @@ export class TraceLoader { return this._backend.hasEntry(filename); } - async resourceForSha1(sha1: string): Promise { - const blob = await this._backend.readBlob('resources/' + sha1); - const contentType = this._resourceToContentType.get(sha1); + async resourceEntry(file: string): Promise { + const blob = await this._backend.readBlob(file); + const contentType = this._resourceToContentType.get(file); // "x-unknown" in the har means "no content type". if (!blob || contentType === undefined || contentType === 'x-unknown') return blob; @@ -139,6 +139,7 @@ function createEmptyContext(): ContextEntry { origin: 'testRunner', startTime: Number.MAX_SAFE_INTEGER, wallTime: Number.MAX_SAFE_INTEGER, + monotonicTime: 0, endTime: 0, browserName: '', options: { @@ -153,6 +154,5 @@ function createEmptyContext(): ContextEntry { errors: [], stdio: [], hasSource: false, - contextId: '', }; } diff --git a/packages/isomorphic/trace/traceModel.ts b/packages/isomorphic/trace/traceModel.ts index eaa75073bfc7c..e1a37b04ed41d 100644 --- a/packages/isomorphic/trace/traceModel.ts +++ b/packages/isomorphic/trace/traceModel.ts @@ -23,11 +23,8 @@ import type { ActionTraceEvent } from '@trace/trace'; import type { ActionEntry, ContextEntry, PageEntry } from './entries'; import type { ActionGroup } from '../protocolFormatter'; -const contextSymbol = Symbol('context'); -const nextInContextSymbol = Symbol('nextInContext'); const prevByEndTimeSymbol = Symbol('prevByEndTime'); const nextByStartTimeSymbol = Symbol('nextByStartTime'); -const eventsSymbol = Symbol('events'); export type SourceLocation = { file: string; @@ -41,21 +38,17 @@ export type SourceModel = { content: string | undefined; }; -export type ResourceEntry = ResourceSnapshot & { id: string }; - -export type ActionTraceEventInContext = ActionEntry & { - context: ContextEntry; -}; +export type ResourceEntry = ResourceSnapshot & { id: string, contextTitle: string }; export type ActionTreeItem = { id: string; children: ActionTreeItem[]; parent: ActionTreeItem | undefined; - action: ActionTraceEventInContext; + action: ActionEntry; }; export type ErrorDescription = { - action?: ActionTraceEventInContext; + action?: ActionEntry; stack?: trace.StackFrame[]; message: string; }; @@ -73,7 +66,7 @@ export class TraceModel { readonly title?: string; readonly options: trace.BrowserContextEventOptions; readonly pages: PageEntry[]; - readonly actions: ActionTraceEventInContext[]; + readonly actions: ActionEntry[]; readonly attachments: Attachment[]; readonly visibleAttachments: Attachment[]; readonly events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]; @@ -91,10 +84,9 @@ export class TraceModel { readonly testTimeout?: number; readonly annotations?: trace.TraceEventAnnotation[]; readonly pagerefToTitle = new Map(); - readonly contextToTitle = new Map(); + private _eventsForAction = new Map(); constructor(traceUri: string, contexts: ContextEntry[]) { - contexts.forEach(contextEntry => indexModel(contextEntry)); const libraryContext = contexts.find(context => context.origin === 'library'); this.traceUri = traceUri; @@ -119,19 +111,18 @@ 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().map(entry => ({ ...entry, id: `${entry.pageref}-${entry.startedDateTime}-${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('_')); - - this.pages.forEach((page, index) => this.pagerefToTitle.set(page.pageId, 'page#' + (index + 1))); + this.resources = []; let lastApiContextId = 0; let lastBrowserContextId = 0; for (const context of contexts) { - if (context.resources.some(resource => resource._apiRequest)) - this.contextToTitle.set(context, 'api#' + (++lastApiContextId)); - else - this.contextToTitle.set(context, 'browser#' + (++lastBrowserContextId)); + 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 }); } + 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.events.sort((a1, a2) => a1.time - a2.time); this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!); @@ -157,6 +148,38 @@ export class TraceModel { return this.actions.findLast(a => a.error); } + eventsForAction(action: ActionEntry): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] { + let result = this._eventsForAction.get(action); + if (result) + return result; + + let nextAction = nextActionByStartTime(action); + while (nextAction && nextAction.class === 'Route') + nextAction = nextActionByStartTime(nextAction); + result = this.events.filter(event => { + return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime); + }); + this._eventsForAction.set(action, result); + return result; + } + + stats(action: ActionEntry): { errors: number, warnings: number } { + let errors = 0; + let warnings = 0; + for (const event of this.eventsForAction(action)) { + if (event.type === 'console') { + const type = event.messageType; + if (type === 'warning') + ++warnings; + else if (type === 'error') + ++errors; + } + if (event.type === 'event' && event.method === 'pageError') + ++errors; + } + return { errors, warnings }; + } + filteredActions(actionsFilter: ActionGroup[]) { const filter = new Set(actionsFilter); return this.actions.filter(action => !action.group || filter.has(action.group)); @@ -198,30 +221,8 @@ export class TraceModel { } } -function indexModel(context: ContextEntry) { - for (const page of context.pages) - (page as any)[contextSymbol] = context; - for (let i = 0; i < context.actions.length; ++i) { - const action = context.actions[i] as any; - action[contextSymbol] = context; - } - let lastNonRouteAction = undefined; - for (let i = context.actions.length - 1; i >= 0; i--) { - const action = context.actions[i] as ActionTraceEvent; - (action as any)[nextInContextSymbol] = lastNonRouteAction; - if (action.class !== 'Route') - lastNonRouteAction = action; - } - 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[]) { - const result: ActionTraceEventInContext[] = []; - const actions = mergeActionsAndUpdateTimingSameTrace(contexts); - result.push(...actions); + const result = mergeActionsAndUpdateTimingSameTrace(contexts); result.sort((a1, a2) => { if (a2.parentId === a1.callId) @@ -250,8 +251,8 @@ function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) { let lastTmpStepId = 0; -function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionTraceEventInContext[] { - const map = new Map(); +function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionEntry[] { + const map = new Map(); const libraryContexts = contexts.filter(context => context.origin === 'library'); const testRunnerContexts = contexts.filter(context => context.origin === 'testRunner'); @@ -259,25 +260,24 @@ function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionT // With library-only or test-runner-only traces there is nothing to match. if (!testRunnerContexts.length || !libraryContexts.length) { return contexts.map(context => { - return context.actions.map(action => ({ ...action, context })); + return context.actions.map(action => ({ ...action })); }).flat(); } + const timeOrigin = (context: ContextEntry) => context.wallTime - context.monotonicTime; + const runnerContext = testRunnerContexts.find(context => context.monotonicTime); + for (const context of libraryContexts) { + if (runnerContext && context.monotonicTime) + adjustMonotonicTime(context, timeOrigin(context) - timeOrigin(runnerContext)); + } + for (const context of libraryContexts) { for (const action of context.actions) { // Never merge stepless events. - map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context }); + map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action }); } } - // Protocol call aka library contexts have startTime/endTime as server-side times. - // Step aka test runner contexts have startTime/endTime as client-side times. - // Adjust startTime/endTime on the library contexts to align them with the test - // runner steps. - const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map); - if (delta) - adjustMonotonicTime(libraryContexts, delta); - const nonPrimaryIdToPrimaryId = new Map(); for (const context of testRunnerContexts) { for (const action of context.actions) { @@ -302,56 +302,39 @@ function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionT } if (action.parentId) action.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId; - map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context }); + map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action }); } } return [...map.values()]; } -function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: number) { - for (const context of contexts) { - context.startTime += monotonicTimeDelta; - context.endTime += monotonicTimeDelta; - for (const action of context.actions) { - if (action.startTime) - action.startTime += monotonicTimeDelta; - if (action.endTime) - action.endTime += monotonicTimeDelta; - } - for (const event of context.events) - event.time += monotonicTimeDelta; - for (const event of context.stdio) - event.timestamp += monotonicTimeDelta; - for (const page of context.pages) { - for (const frame of page.screencastFrames) - frame.timestamp += monotonicTimeDelta; - } - for (const resource of context.resources) { - if (resource._monotonicTime) - resource._monotonicTime += monotonicTimeDelta; - } +function adjustMonotonicTime(context: ContextEntry, monotonicTimeDelta: number) { + if (!monotonicTimeDelta) + return; + context.startTime += monotonicTimeDelta; + context.endTime += monotonicTimeDelta; + context.monotonicTime += monotonicTimeDelta; + for (const action of context.actions) { + if (action.startTime) + action.startTime += monotonicTimeDelta; + if (action.endTime) + action.endTime += monotonicTimeDelta; } -} - -function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map) { - // We cannot rely on wall time or monotonic time to be the in sync - // between library and test runner contexts. So we find first action - // that is present in both runner and library contexts and use it - // to calculate the time delta, assuming the two events happened at the - // same instant. - for (const context of nonPrimaryContexts) { - for (const action of context.actions) { - if (!action.startTime) - continue; - const libraryAction = action.stepId ? libraryActions.get(action.stepId) : undefined; - if (libraryAction) - return action.startTime - libraryAction.startTime; - } + for (const event of context.events) + event.time += monotonicTimeDelta; + for (const event of context.stdio) + event.timestamp += monotonicTimeDelta; + for (const page of context.pages) { + for (const frame of page.screencastFrames) + frame.timestamp += monotonicTimeDelta; + } + for (const resource of context.resources) { + if (resource._monotonicTime) + resource._monotonicTime += monotonicTimeDelta; } - return 0; } -export function buildActionTree(actions: ActionTraceEventInContext[]): { rootItem: ActionTreeItem, itemMap: Map } { +export function buildActionTree(actions: ActionEntry[]): { rootItem: ActionTreeItem, itemMap: Map } { const itemMap = new Map(); for (const action of actions) { @@ -383,14 +366,6 @@ export function buildActionTree(actions: ActionTraceEventInContext[]): { rootIte return { rootItem, itemMap }; } -export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { - return (action as any)[contextSymbol]; -} - -function nextInContext(action: ActionTraceEvent): ActionTraceEvent { - return (action as any)[nextInContextSymbol]; -} - export function previousActionByEndTime(action: ActionTraceEvent): ActionTraceEvent { return (action as any)[prevByEndTimeSymbol]; } @@ -399,36 +374,6 @@ export function nextActionByStartTime(action: ActionTraceEvent): ActionTraceEven return (action as any)[nextByStartTimeSymbol]; } -export function stats(action: ActionTraceEvent): { errors: number, warnings: number } { - let errors = 0; - let warnings = 0; - for (const event of eventsForAction(action)) { - if (event.type === 'console') { - const type = event.messageType; - if (type === 'warning') - ++warnings; - else if (type === 'error') - ++errors; - } - if (event.type === 'event' && event.method === 'pageError') - ++errors; - } - return { errors, warnings }; -} - -export function eventsForAction(action: ActionTraceEvent): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] { - let result: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] = (action as any)[eventsSymbol]; - if (result) - return result; - - const nextAction = nextInContext(action); - result = context(action).events.filter(event => { - return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime); - }); - (action as any)[eventsSymbol] = result; - return result; -} - function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: ErrorDescription[]): Map { const result = new Map(); for (const action of actions) { @@ -453,7 +398,7 @@ function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: Err return result; } -const kFakeRootAction: ActionTraceEventInContext = { +const kFakeRootAction: ActionEntry = { type: 'action', callId: '', startTime: 0, @@ -462,20 +407,4 @@ const kFakeRootAction: ActionTraceEventInContext = { method: '', params: {}, log: [], - context: { - origin: 'library', - startTime: 0, - endTime: 0, - browserName: '', - wallTime: 0, - options: {}, - pages: [], - resources: [], - actions: [], - events: [], - stdio: [], - errors: [], - hasSource: false, - contextId: '', - }, }; diff --git a/packages/isomorphic/trace/traceModernizer.ts b/packages/isomorphic/trace/traceModernizer.ts index b18662b16e24b..a1ddaf3dd2277 100644 --- a/packages/isomorphic/trace/traceModernizer.ts +++ b/packages/isomorphic/trace/traceModernizer.ts @@ -93,11 +93,11 @@ export class TraceModernizer { contextEntry.platform = event.platform; contextEntry.playwrightVersion = event.playwrightVersion; contextEntry.wallTime = event.wallTime; + contextEntry.monotonicTime = event.monotonicTime; contextEntry.startTime = event.monotonicTime; contextEntry.sdkLanguage = event.sdkLanguage; contextEntry.options = event.options; contextEntry.testIdAttributeName = event.testIdAttributeName; - contextEntry.contextId = event.contextId ?? ''; contextEntry.testTimeout = event.testTimeout; contextEntry.annotations = event.annotations; break; @@ -164,11 +164,11 @@ export class TraceModernizer { break; } case 'resource-snapshot': - this._snapshotStorage.addResource(this._contextEntry.contextId, event.snapshot); + this._snapshotStorage.addResource(event.snapshot); contextEntry.resources.push(event.snapshot); break; case 'frame-snapshot': - this._snapshotStorage.addFrameSnapshot(this._contextEntry.contextId, event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames); + this._snapshotStorage.addFrameSnapshot(event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames); break; } // Make sure there is a page entry for each page, even without screencast frames, @@ -200,9 +200,48 @@ export class TraceModernizer { let events = [event]; for (; version < latestVersion; ++version) events = (this as any)[`_modernize_${version}_to_${version + 1}`].call(this, events); + for (const e of events) + this._normalizeResourceReferences(e); return events; } + // Traces recorded before trace-relative paths referenced blobs by bare sha1-style names: + // `_sha1` in har entry content, `sha1` in snapshot resource overrides, screencast frames + // and attachments. + private _normalizeResourceReferences(event: any) { + if (event.type === 'resource-snapshot') { + const { request, response } = event.snapshot; + if (request?.postData?._sha1) { + request.postData._file = 'resources/' + request.postData._sha1; + delete request.postData._sha1; + } + if (response?.content?._sha1) { + response.content._file = 'resources/' + response.content._sha1; + delete response.content._sha1; + } + } + if (event.type === 'frame-snapshot') { + for (const override of event.snapshot.resourceOverrides || []) { + if (override.sha1) { + override.file = 'resources/' + override.sha1; + delete override.sha1; + } + } + } + if (event.type === 'screencast-frame' && event.sha1) { + event.file = 'resources/' + event.sha1; + delete event.sha1; + } + if (event.type === 'after' || event.type === 'action') { + for (const attachment of event.attachments || []) { + if (attachment.sha1) { + attachment.file = 'resources/' + attachment.sha1; + delete attachment.sha1; + } + } + } + } + _modernize_0_to_1(events: any[]): any[] { for (const event of events) { if (event.type !== 'action') @@ -407,9 +446,10 @@ export class TraceModernizer { continue; } if (event.type === 'before' || event.type === 'action') { - // Take wall and monotonic time from the first event. - if (!this._contextEntry.wallTime) + if (!this._contextEntry.monotonicTime) { + this._contextEntry.monotonicTime = (event as traceV6.BeforeActionTraceEvent).startTime; this._contextEntry.wallTime = event.wallTime; + } const eventAsV6 = event as traceV6.BeforeActionTraceEvent; const eventAsV7 = event as traceV7.BeforeActionTraceEvent; eventAsV7.stepId = `${eventAsV6.apiName}@${eventAsV6.wallTime}`; diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 1b92b5acb8714..da0a75d1bf7a5 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -20479,6 +20479,9 @@ export interface APIRequestContext { }>; }>; + /** + * Tracing recorder for requests made through this API request context. + */ tracing: Tracing; [Symbol.asyncDispose](): Promise; @@ -23771,8 +23774,8 @@ export interface Tracing { * [tracing.stopHar()](https://playwright.dev/docs/api/class-tracing#tracing-stop-har) is called, or when the returned * [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. * - * Only one HAR recording can be active at a time per - * [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). + * Only one HAR recording can be active at a time per [Tracing](https://playwright.dev/docs/api/class-tracing) + * instance. * * **Usage** * diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 368e518bb97f5..4a9736ebde6cd 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -118,6 +118,7 @@ export class Browser extends ChannelOwner implements ap private _setupBrowserContext(context: BrowserContext) { context._logger = this._logger; context.tracing._tracesDir = this._options.tracesDir; + context.request.tracing._tracesDir = this._options.tracesDir; this._browserType._contexts.add(context); this._browserType._playwright.selectors._contextsForSelectors.add(context); context.setDefaultTimeout(this._browserType._playwright._defaultContextTimeout); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index c54f131d4f7a5..3cd2f2df642bc 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -513,6 +513,7 @@ export class BrowserContext extends ChannelOwner this._browser?._browserType._playwright.selectors._contextsForSelectors.delete(this); this._disposeHarRouters(); this.tracing._resetStackCounter(); + this.request.tracing._resetStackCounter(); this.emit(Events.BrowserContext.Close, this); } diff --git a/packages/playwright-core/src/client/electron.ts b/packages/playwright-core/src/client/electron.ts index 927c8a4b9d1ad..ffbba3bb255df 100644 --- a/packages/playwright-core/src/client/electron.ts +++ b/packages/playwright-core/src/client/electron.ts @@ -69,6 +69,7 @@ export class Electron extends ChannelOwner implements app.once(Events.ElectronApplication.Close, () => this._playwright.selectors._contextsForSelectors.delete(app._context)); await app._context._initializeHarFromOptions(options.recordHar); app._context.tracing._tracesDir = options.tracesDir; + app._context.request.tracing._tracesDir = options.tracesDir; return app; } } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 91ee14bcc6f4d..2dc2b84222073 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -222,6 +222,7 @@ export abstract class BrowserContext extends Sdk async resetForReuse(progress: Progress, params: channels.BrowserNewContextForReuseParams | null) { await this.tracing.resetForReuse(progress); + await this.fetchRequest.tracing().resetForReuse(progress); if (params) { for (const key of paramsThatAllowContextReuse) @@ -269,6 +270,7 @@ export abstract class BrowserContext extends Sdk this._closedStatus = 'closed'; this._clientCertificatesProxy?.close().catch(() => {}); this.tracing.abort(); + this.fetchRequest.tracing().abort(); this._closePromiseFulfill!(new Error('Context closed')); this.emit(BrowserContext.Events.Close); } @@ -558,7 +560,10 @@ export abstract class BrowserContext extends Sdk this.emit(BrowserContext.Events.BeforeClose); this._closedStatus = 'closing'; - await progress.race(this.tracing.flush()); + await progress.race(Promise.all([ + this.tracing.flush(), + this.fetchRequest.tracing().flush(), + ])); await progress.race(Promise.all(this.pages().map(page => page.screencast.handlePageOrContextClose()))); if (this._customCloseHandler) { diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 193e3ad50a7f6..f9a973699f206 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -557,10 +557,10 @@ export class CRNetworkManager { } } -// Sec-Fetch-Dest values of static subresources that are safe to fetch again. -const kRefetchSafeDestinations = new Set([ - 'audio', 'audioworklet', 'font', 'image', 'manifest', 'paintworklet', 'script', - 'serviceworker', 'sharedworker', 'style', 'track', 'video', 'worker', 'xslt', +// Resource types of static subresources that are safe to fetch again. Unlike +// Sec-Fetch-Dest, resource type is reported for plain http origins as well. +const kRefetchSafeResourceTypes = new Set([ + 'font', 'image', 'manifest', 'media', 'script', 'stylesheet', 'texttrack', ]); const kInterceptableRequest = Symbol('InterceptableRequest'); @@ -609,12 +609,12 @@ class InterceptableRequest { // do it for GETs of static subresources and prefetch requests. if (request.method() !== 'GET') return Buffer.from(''); - const rawHeaders = await request.internalRawRequestHeaders(); - const rawHeaderValue = (name: string) => rawHeaders.find(h => h.name.toLowerCase() === name)?.value; - const isPrefetch = !!rawHeaderValue('sec-purpose')?.startsWith('prefetch'); - const secFetchDest = rawHeaderValue('sec-fetch-dest'); - if (!isPrefetch && (!secFetchDest || !kRefetchSafeDestinations.has(secFetchDest))) - return Buffer.from(''); + if (!kRefetchSafeResourceTypes.has(request.resourceType())) { + const rawHeaders = await request.internalRawRequestHeaders(); + const isPrefetch = rawHeaders.some(h => h.name.toLowerCase() === 'sec-purpose' && h.value.startsWith('prefetch')); + if (!isPrefetch) + return Buffer.from(''); + } const resource = await session.send('Network.loadNetworkResource', { url: request.url(), frameId: request.serviceWorker() ? undefined : request.frame()!._id, options: { disableCache: false, includeCredentials: true } }); const chunks: Buffer[] = []; diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 12d91c0fc1bd4..52aab753da552 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -49,6 +49,22 @@ import type * as channels from '../channels'; export type WindowBounds = { top?: number, left?: number, width?: number, height?: number }; +// Browsers disallow these WebUI hosts in off-the-record profiles and redirect them to the original +// profile, which crashes when the profile was created over CDP. Edge allows most of them in InPrivate. +// See https://github.com/microsoft/playwright/issues/41935. +const kCrashingWebUIHosts = { + chromium: new Set(['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']), + edge: new Set(['history']), +}; + +// Chromium canonicalizes WebUI urls as standard ones, so "VIEW-SOURCE:Chrome:Settings" ends up +// being "chrome://settings/". +function webUIHost(url: string): string { + const match = /^(?:view-source:)?(?:chrome|edge):\/*([^/?#]+)/i.exec(url); + const authority = match ? `http://${match[1]}` : ''; + return URL.canParse(authority) ? new URL(authority).hostname : ''; +} + export class CRPage implements PageDelegate { readonly utilityWorldName: string; readonly _mainFrameSession: FrameSession; @@ -152,9 +168,18 @@ export class CRPage implements PageDelegate { } async navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise { + this._assertNavigationDoesNotCrashBrowser(url); return this._sessionForFrame(frame)._navigate(frame, url, referrer); } + private _assertNavigationDoesNotCrashBrowser(url: string) { + if (this._browserContext.isPersistentContext()) + return; + const isEdge = this._browserContext._browser.userAgent().includes('Edg/'); + if ((isEdge ? kCrashingWebUIHosts.edge : kCrashingWebUIHosts.chromium).has(webUIHost(url))) + throw new Error(`Cannot navigate to "${url}": this page is not available in an isolated browser context, and opening it crashes the browser. Use browserType.launchPersistentContext() instead.`); + } + async updateExtraHTTPHeaders(): Promise { const headers = network.mergeHeaders([ this._browserContext._options.extraHTTPHeaders, diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index d49f1619e6a68..27a520fea4719 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -125,6 +125,7 @@ export abstract class APIRequestContext extends SdkObject { constructor(parent: SdkObject) { super(parent, 'request-context'); + this.attribution.context = this; APIRequestContext.allInstances.add(this); } @@ -676,19 +677,22 @@ class SafeEmptyStreamTransform extends Transform { export class BrowserContextAPIRequestContext extends APIRequestContext { private readonly _context: BrowserContext; + private readonly _tracing: Tracing; constructor(context: BrowserContext) { super(context); this._context = context; + this._tracing = new Tracing(this, context._browser.options.tracesDir); context.once(BrowserContext.Events.Close, () => this._disposeImpl()); } override tracing() { - return this._context.tracing; + return this._tracing; } override async dispose(options: { reason?: string }) { this._closeReason = options.reason; + await this._tracing.flush(); this.fetchResponses.clear(); } @@ -727,7 +731,6 @@ export class GlobalAPIRequestContext extends APIRequestContext { constructor(playwright: Playwright, options: channels.PlaywrightNewRequestOptions) { super(playwright); - this.attribution.context = this; if (options.storageState) { this._origins = options.storageState.origins?.map(origin => ({ indexedDB: [], ...origin })); this._cookieStore.addCookies(options.storageState.cookies || []); diff --git a/packages/playwright-core/src/server/har/harRecorder.ts b/packages/playwright-core/src/server/har/harRecorder.ts index 78ce1fe0f8d74..360cfd34df6de 100644 --- a/packages/playwright-core/src/server/har/harRecorder.ts +++ b/packages/playwright-core/src/server/har/harRecorder.ts @@ -33,6 +33,7 @@ export class HarRecorder implements HarTracerDelegate { private _fs = new SerializedFS(); private _harFilePath: string; private _resourcesDir: string; + private _relativeResourcesDir: string; private _isFlushed: boolean = false; private _tracer: HarTracer; private _entries: har.Entry[] = []; @@ -42,18 +43,23 @@ export class HarRecorder implements HarTracerDelegate { this._context = context; const isServer = !!context.attribution.playwright.options.isServer; this._harFilePath = !isServer && options.harPath ? options.harPath : path.join(fallbackDir, `${harId}.har`); - if (!isServer && options.resourcesDir) + const harFileDir = path.dirname(this._harFilePath); + if (!isServer && options.resourcesDir) { this._resourcesDir = options.resourcesDir; - else if (!isServer && options.harPath) - this._resourcesDir = path.dirname(options.harPath); - else + this._relativeResourcesDir = path.relative(harFileDir, this._resourcesDir).split(path.sep).join('/'); + } else if (!isServer && options.harPath) { + this._resourcesDir = harFileDir; + this._relativeResourcesDir = ''; + } else { + // Staging layout for the zip archive, where resources end up next to har.har. this._resourcesDir = path.join(fallbackDir, `${harId}-resources`); + this._relativeResourcesDir = ''; + } const urlFilterRe = options.urlRegexSource !== undefined && options.urlRegexFlags !== undefined ? new RegExp(options.urlRegexSource, options.urlRegexFlags) : undefined; const content = options.content || 'embed'; this._tracer = new HarTracer(context, page, this, { content, slimMode: options.mode === 'minimal', - includeTraceInfo: false, recordRequestOverrides: true, waitForContentOnStop: true, urlFilter: urlFilterRe ?? options.urlGlob, @@ -69,20 +75,28 @@ export class HarRecorder implements HarTracerDelegate { onEntryFinished(entry: har.Entry) { } - onContentBlob(sha1: string, buffer: Buffer) { - if (this._writtenContentEntries.has(sha1)) - return; + onContentBlob(shortName: string, buffer: Buffer): string { + const fullName = this._harRelativePath(shortName); + if (this._writtenContentEntries.has(shortName)) + return fullName; if (!this._writtenContentEntries.size) this._fs.mkdir(this._resourcesDir); - this._writtenContentEntries.add(sha1); - this._fs.writeFile(path.join(this._resourcesDir, sha1), buffer, true /* skipIfExists */); + this._writtenContentEntries.add(shortName); + this._fs.writeFile(path.join(this._resourcesDir, shortName), buffer, true /* skipIfExists */); + return fullName; } - onContentBlobAppend(sha1: string, text: string) { + onContentBlobAppend(shortName: string, text: string) { + const fullName = this._harRelativePath(shortName); if (!this._writtenContentEntries.size) this._fs.mkdir(this._resourcesDir); - this._writtenContentEntries.add(sha1); - this._fs.appendFile(path.join(this._resourcesDir, sha1), text); + this._writtenContentEntries.add(shortName); + this._fs.appendFile(path.join(this._resourcesDir, shortName), text); + return fullName; + } + + private _harRelativePath(shortName: string): string { + return this._relativeResourcesDir ? this._relativeResourcesDir + '/' + shortName : shortName; } private async _flush() { diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 4817c94112edc..f24b39d6cd126 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -44,13 +44,12 @@ const FALLBACK_HTTP_VERSION = 'HTTP/1.1'; export interface HarTracerDelegate { onEntryStarted(entry: har.Entry): void; onEntryFinished(entry: har.Entry): void; - onContentBlob(sha1: string, buffer: Buffer): void; - onContentBlobAppend(sha1: string, text: string): void; + onContentBlob(shortName: string, buffer: Buffer): string; + onContentBlobAppend(shortName: string, text: string): string; } type HarTracerOptions = { content: 'omit' | 'attach' | 'embed'; - includeTraceInfo: boolean; recordRequestOverrides: boolean; waitForContentOnStop: boolean; urlFilter?: string | RegExp; @@ -105,11 +104,7 @@ export class HarTracer { return; this._options.omitScripts = options.omitScripts; this._started = true; - const apiRequest = this._context instanceof APIRequestContext ? this._context : this._context.fetchRequest; - this._eventListeners = [ - eventsHelper.addEventListener(apiRequest, APIRequestContext.Events.Request, (event: APIRequestEvent) => this._onAPIRequest(event)), - eventsHelper.addEventListener(apiRequest, APIRequestContext.Events.RequestFinished, (event: APIRequestFinishedEvent) => this._onAPIRequestFinished(event)), - ]; + this._eventListeners = []; if (this._context instanceof BrowserContext) { this._eventListeners.push( eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => this._createPageEntryIfNeeded(page)), @@ -125,6 +120,12 @@ export class HarTracer { for (const page of this._context.pages()) this._createPageEntryIfNeeded(page); } + if (this._context instanceof APIRequestContext) { + this._eventListeners.push( + eventsHelper.addEventListener(this._context, APIRequestContext.Events.Request, (event: APIRequestEvent) => this._onAPIRequest(event)), + eventsHelper.addEventListener(this._context, APIRequestContext.Events.RequestFinished, (event: APIRequestFinishedEvent) => this._onAPIRequestFinished(event)), + ); + } } private _shouldIncludeEntryWithUrl(urlString: string) { @@ -409,6 +410,8 @@ export class HarTracer { if (request._failureText !== null) harEntry.response._failureText = request._failureText; + if (harEntry._monotonicTime && harEntry.time === -1) + harEntry.time = monotonicTime() - harEntry._monotonicTime; this._recordRequestOverrides(harEntry, request); if (this._started) this._delegate.onEntryFinished(harEntry); @@ -446,7 +449,7 @@ export class HarTracer { const harEntry = createHarEntry(pageEntry?.id, method, url, page.mainFrame().guid, this._options, webSocket.wallTimeMs()); harEntry._resourceType = 'websocket'; - let sha1: string | undefined = undefined; + const shortName = createGuid() + '.jsonl'; const recordMessage = (type: 'send' | 'receive', opcode: number, data: string, wallTimeMs: number) => { if (this._omitWebSocketFrames) return; @@ -455,16 +458,9 @@ export class HarTracer { harEntry._webSocketMessages ??= []; harEntry._webSocketMessages.push(message); } else if (this._options.content === 'attach') { - if (!sha1) { - sha1 = createGuid() + '.jsonl'; - if (this._options.includeTraceInfo) - harEntry.response.content._sha1 = sha1; - else - harEntry.response.content._file = sha1; - } if (this._started) - this._delegate.onContentBlobAppend(sha1, JSON.stringify(message) + '\n'); + harEntry.response.content._file = this._delegate.onContentBlobAppend(shortName, JSON.stringify(message) + '\n'); } }; @@ -561,13 +557,9 @@ export class HarTracer { content.encoding = 'base64'; } } else if (this._options.content === 'attach') { - const sha1 = calculateSha1(buffer) + '.' + (mime.getExtension(content.mimeType) || 'dat'); - if (this._options.includeTraceInfo) - content._sha1 = sha1; - else - content._file = sha1; + const shortName = calculateSha1(buffer) + '.' + (mime.getExtension(content.mimeType) || 'dat'); if (this._started) - this._delegate.onContentBlob(sha1, buffer); + content._file = this._delegate.onContentBlob(shortName, buffer); } } @@ -722,12 +714,8 @@ export class HarTracer { result.text = postData.toString(); if (content === 'attach') { - const sha1 = calculateSha1(postData) + '.' + (mime.getExtension(contentType) || 'dat'); - if (this._options.includeTraceInfo) - result._sha1 = sha1; - else - result._file = sha1; - this._delegate.onContentBlob(sha1, postData); + const shortName = calculateSha1(postData) + '.' + (mime.getExtension(contentType) || 'dat'); + result._file = this._delegate.onContentBlob(shortName, postData); } if (contentType === 'application/x-www-form-urlencoded') { @@ -778,8 +766,8 @@ function createHarEntry(pageRef: string | undefined, method: string, url: URL, f wait: -1, receive: -1 }, - _frameref: options.includeTraceInfo ? frameref : undefined, - _monotonicTime: options.includeTraceInfo ? monotonicTime() : undefined, + _frameref: frameref, + _monotonicTime: monotonicTime(), }; return harEntry; } diff --git a/packages/playwright-core/src/server/localUtils.ts b/packages/playwright-core/src/server/localUtils.ts index c2b36758a6a94..ba32e9ed38d03 100644 --- a/packages/playwright-core/src/server/localUtils.ts +++ b/packages/playwright-core/src/server/localUtils.ts @@ -75,7 +75,7 @@ export async function zip(progress: Progress, stackSessions: Map, - traceSha1s: Set, + // Blobs referenced by the network stream. The network file is preserved between + // chunks (for browser contexts), so these are included in every chunk's archive. + crossChunkFiles: Set, + // Blobs referenced by the current chunk's trace stream, reset on every stopChunk. + chunkFiles: Set, recording: boolean; callsInProgress: Set; groupStack: string[]; @@ -107,7 +109,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._precreatedTracesDir = tracesDir; this._harTracer = new HarTracer(context, null, this, { content: 'attach', - includeTraceInfo: true, recordRequestOverrides: false, waitForContentOnStop: false, }); @@ -124,7 +125,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps monotonicTime: 0, sdkLanguage: this._sdkLanguage(), testIdAttributeName, - contextId: context.guid, }; if (context instanceof BrowserContext) { this._snapshotter = new Snapshotter(context, this); @@ -169,15 +169,20 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps tracesDir, traceFile: path.join(tracesDir, traceName + '.trace'), networkFile: path.join(tracesDir, traceName + '.network'), - resourcesDir: path.join(tracesDir, 'resources'), chunkOrdinal: 0, - traceSha1s: new Set(), - networkSha1s: new Set(), + chunkFiles: new Set(), + crossChunkFiles: new Set(), recording: false, callsInProgress: new Set(), groupStack: [], }; - this._fs.mkdir(this._state.resourcesDir); + this._fs.mkdir(path.join(tracesDir, 'resources')); + if (options.screencast) + this._fs.mkdir(path.join(tracesDir, 'screencast')); + if (options.snapshotScreen) + this._fs.mkdir(path.join(tracesDir, 'screenshots')); + if (options.snapshotAria) + this._fs.mkdir(path.join(tracesDir, 'aria')); this._fs.writeFile(this._state.networkFile, ''); // Tracing is 10x bigger if we include scripts in every trace. if (options.snapshotDom) @@ -205,8 +210,10 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._changeTraceName(this._state, options.name, preserveNetworkResources); else this._allocateNewTraceFile(this._state); - if (!preserveNetworkResources) + if (!preserveNetworkResources) { + this._state.crossChunkFiles = new Set(); this._fs.writeFile(this._state.networkFile, ''); + } this._fs.mkdir(path.dirname(this._state.traceFile)); const event: trace.TraceEvent = { @@ -415,11 +422,10 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps const entries: NameValue[] = []; entries.push({ name: 'trace.trace', value: this._state.traceFile }); entries.push({ name: 'trace.network', value: newNetworkFile }); - for (const sha1 of new Set([...this._state.traceSha1s, ...this._state.networkSha1s])) - entries.push({ name: path.join('resources', sha1), value: path.join(this._state.resourcesDir, sha1) }); + for (const file of new Set([...this._state.chunkFiles, ...this._state.crossChunkFiles])) + entries.push({ name: file, value: path.join(this._state.tracesDir, file) }); - // Only reset trace sha1s, network resources are preserved between chunks. - this._state.traceSha1s = new Set(); + this._state.chunkFiles = new Set(); if (params.mode === 'discard') { this._isStopping = false; @@ -450,7 +456,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps if (error) { // This check is here because closing the browser removes the tracesDir and tracing // cannot access removed files. Clients are ready for the missing artifact. - if (!isAbortError(error) && this._context instanceof BrowserContext && !this._context._browser.isConnected()) + if (!isAbortError(error) && this._context.attribution.browser && !this._context.attribution.browser.isConnected()) return {}; throw error; } @@ -507,9 +513,10 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps const buffer = await page.screenshot(progress, { type: 'png' }).catch(() => undefined); if (!buffer || !this._state?.recording) return; - const sha1 = calculateSha1(buffer) + '.png'; - this._appendResource(sha1, buffer); - this._appendTraceEvent({ type: 'screenshot', callId: progress.metadata.id, phase, sha1 }); + const file = `screenshots/${progress.metadata.id}-${phase}.png`; + this._state.chunkFiles.add(file); + this._appendResource(file, buffer); + this._appendTraceEvent({ type: 'screenshot', callId: progress.metadata.id, phase, file }); } private async _captureAriaSnapshot(progress: Progress, page: Page, phase: trace.ActionPhase): Promise { @@ -517,9 +524,10 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps if (!snapshot || !this._state?.recording) return; const buffer = Buffer.from(JSON.stringify(snapshot), 'utf8'); - const sha1 = calculateSha1(buffer) + '.json'; - this._appendResource(sha1, buffer); - this._appendTraceEvent({ type: 'aria-snapshot', callId: progress.metadata.id, phase, sha1 }); + const file = `aria/${progress.metadata.id}-${phase}.json`; + this._state.chunkFiles.add(file); + this._appendResource(file, buffer); + this._appendTraceEvent({ type: 'aria-snapshot', callId: progress.metadata.id, phase, file }); } onBeforeCall(progress: Progress, sdkObject: SdkObject, parentId?: string) { @@ -586,7 +594,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps onEntryFinished(entry: har.Entry) { this._pendingHarEntries.delete(entry); const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot: entry }; - const visited = visitTraceEvent(event, this._state!.networkSha1s); + const visited = visitTraceEvent(event); this._fs.appendFile(this._state!.networkFile, JSON.stringify(visited) + '\n', true /* flush */); } @@ -594,7 +602,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps const harLines: string[] = []; for (const entry of this._pendingHarEntries) { const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot: entry }; - const visited = visitTraceEvent(event, this._state!.networkSha1s); + const visited = visitTraceEvent(event); harLines.push(JSON.stringify(visited)); } this._pendingHarEntries.clear(); @@ -602,18 +610,27 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._fs.appendFile(this._state!.networkFile, harLines.join('\n') + '\n', true /* flush */); } - onContentBlob(sha1: string, buffer: Buffer) { - this._appendResource(sha1, buffer); + onContentBlob(shortName: string, buffer: Buffer) { + const file = `resources/${shortName}`; + this._state!.crossChunkFiles.add(file); + this._appendResource(file, buffer); + return file; } - onContentBlobAppend(sha1: string, text: string) { - if (!this._allResources.has(sha1)) - this._allResources.add(sha1); - this._fs.appendFile(path.join(this._state!.resourcesDir, sha1), text, this._state!.options.live /* flush */); + onContentBlobAppend(shortName: string, text: string) { + const file = `resources/${shortName}`; + this._state!.crossChunkFiles.add(file); + if (!this._allResources.has(file)) + this._allResources.add(file); + this._fs.appendFile(path.join(this._state!.tracesDir, file), text, this._state!.options.live /* flush */); + return file; } - onSnapshotterBlob(blob: SnapshotterBlob): void { - this._appendResource(blob.sha1, blob.buffer); + onSnapshotterBlob(blob: SnapshotterBlob): string { + const file = `resources/${blob.sha1}`; + this._state!.chunkFiles.add(file); + this._appendResource(file, blob.buffer); + return file; } onFrameSnapshot(snapshot: FrameSnapshot): void { @@ -714,42 +731,43 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps const prefix = page.guid; const onFrame = (params: types.ScreencastFrame) => { const suffix = Date.now(); - const sha1 = `${prefix}-${suffix}.jpeg`; + const file = `screencast/${prefix}-${suffix}.jpeg`; const event: trace.ScreencastFrameTraceEvent = { type: 'screencast-frame', pageId: page.guid, - sha1, + file, width: params.viewportWidth, height: params.viewportHeight, timestamp: monotonicTime(), frameSwapWallTime: params.frameSwapWallTime, }; // Make sure to write the screencast frame before adding a reference to it. - this._appendResource(sha1, params.buffer); + this._state!.chunkFiles.add(file); + this._appendResource(file, params.buffer); this._appendTraceEvent(event); }; this._pageTracingRecorders.set(page, new ScreencastTracingRecorder(page.screencast, onFrame)); } private _appendTraceEvent(event: trace.TraceEvent) { - const visited = visitTraceEvent(event, this._state!.traceSha1s); + const visited = visitTraceEvent(event); // Do not flush (console) events, they are too noisy, unless we are in ui mode (live). const flush = this._state!.options.live || (event.type !== 'event' && event.type !== 'console' && event.type !== 'log'); this._fs.appendFile(this._state!.traceFile, JSON.stringify(visited) + '\n', flush); } - private _appendResource(sha1: string, buffer: Buffer) { - if (this._allResources.has(sha1)) + private _appendResource(file: string, buffer: Buffer) { + if (this._allResources.has(file)) return; - this._allResources.add(sha1); - const resourcePath = path.join(this._state!.resourcesDir, sha1); + this._allResources.add(file); + const resourcePath = path.join(this._state!.tracesDir, file); this._fs.writeFile(resourcePath, buffer, true /* skipIfExists */); } } -function visitTraceEvent(object: any, sha1s: Set): any { +function visitTraceEvent(object: any): any { if (Array.isArray(object)) - return object.map(o => visitTraceEvent(o, sha1s)); + return object.map(o => visitTraceEvent(o)); if (object instanceof Dispatcher) return `<${(object as Dispatcher)._type}>`; if (object instanceof Buffer) @@ -758,14 +776,8 @@ function visitTraceEvent(object: any, sha1s: Set): any { return object; if (typeof object === 'object') { const result: any = {}; - for (const key in object) { - if (key === 'sha1' || key === '_sha1' || key.endsWith('Sha1')) { - const sha1 = object[key]; - if (sha1) - sha1s.add(sha1); - } - result[key] = visitTraceEvent(object[key], sha1s); - } + for (const key in object) + result[key] = visitTraceEvent(object[key]); return result; } return object; diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index c06de739ff702..275a09951f377 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -334,10 +334,12 @@ function traceDescriptor(traceDir: string, tracePrefix: string | undefined) { result.entries.push({ name, path: toFilePathUrl(path.join(traceDir, name)) }); } - const resourcesDir = path.join(traceDir, 'resources'); - if (fs.existsSync(resourcesDir)) { - for (const name of fs.readdirSync(resourcesDir)) - result.entries.push({ name: 'resources/' + name, path: toFilePathUrl(path.join(resourcesDir, name)) }); + for (const dir of ['resources', 'screencast', 'screenshots', 'aria', 'attachments', 'src']) { + const dirPath = path.join(traceDir, dir); + if (fs.existsSync(dirPath)) { + for (const name of fs.readdirSync(dirPath)) + result.entries.push({ name: dir + '/' + name, path: toFilePathUrl(path.join(dirPath, name)) }); + } } return result; } diff --git a/packages/playwright-core/src/tools/skills/playwright-trace/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-trace/SKILL.md index cc2e80a30b85f..c1da2ef591e7f 100644 --- a/packages/playwright-core/src/tools/skills/playwright-trace/SKILL.md +++ b/packages/playwright-core/src/tools/skills/playwright-trace/SKILL.md @@ -60,7 +60,7 @@ The `action` command displays available snapshot phases (before, input, after) a ### Requests ```bash -# All network requests: method, status, URL, duration, size +# All network requests: start time (on the `trace actions` clock), method, status, URL, duration, size npx playwright trace requests # Filter by URL pattern diff --git a/packages/playwright-core/src/tools/trace/traceActions.ts b/packages/playwright-core/src/tools/trace/traceActions.ts index 2e11a78157d57..3b75d9c1bda2f 100644 --- a/packages/playwright-core/src/tools/trace/traceActions.ts +++ b/packages/playwright-core/src/tools/trace/traceActions.ts @@ -21,7 +21,7 @@ import { asLocatorDescription } from '@isomorphic/locatorGenerators'; import { msToString } from '@isomorphic/formatUtils'; import { loadTrace, formatTimestamp, actionTitle } from './traceUtils'; -import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; +import type { ActionEntry } from '@isomorphic/trace/entries'; import type { Language } from '@isomorphic/locatorGenerators'; export async function traceActions(options: { grep?: string, errorsOnly?: boolean }) { @@ -37,8 +37,8 @@ export async function traceActions(options: { grep?: string, errorsOnly?: boolea const ordinal = trace.callIdToOrdinal.get(action.callId) ?? '?'; const ts = formatTimestamp(action.startTime, trace.model.startTime); const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'running'; - const title = actionTitle(action as ActionTraceEventInContext); - const locator = actionLocator(action as ActionTraceEventInContext); + const title = actionTitle(action); + const locator = actionLocator(action); const error = action.error ? ' ✗' : ''; const prefix = ` ${(ordinal + '.').padStart(4)} ${ts} ${indent}`; console.log(`${prefix}${title.padEnd(Math.max(1, 55 - indent.length))} ${duration.padStart(8)}${error}`); @@ -51,7 +51,7 @@ export async function traceActions(options: { grep?: string, errorsOnly?: boolea visit(child, ''); } -function filterActions(actions: ActionTraceEventInContext[], options: { grep?: string, errorsOnly?: boolean }): ActionTraceEventInContext[] { +function filterActions(actions: ActionEntry[], options: { grep?: string, errorsOnly?: boolean }): ActionEntry[] { let result = actions.filter(a => a.group !== 'configuration'); if (options.grep) { const pattern = new RegExp(options.grep, 'i'); @@ -62,7 +62,7 @@ function filterActions(actions: ActionTraceEventInContext[], options: { grep?: s return result; } -function actionLocator(action: ActionTraceEventInContext, sdkLanguage?: Language): string | undefined { +function actionLocator(action: ActionEntry, sdkLanguage?: Language): string | undefined { return action.params.selector ? asLocatorDescription(sdkLanguage || 'javascript', action.params.selector) : undefined; } diff --git a/packages/playwright-core/src/tools/trace/traceAttachments.ts b/packages/playwright-core/src/tools/trace/traceAttachments.ts index 5c8b51de95aef..186748461d28b 100644 --- a/packages/playwright-core/src/tools/trace/traceAttachments.ts +++ b/packages/playwright-core/src/tools/trace/traceAttachments.ts @@ -49,8 +49,8 @@ export async function traceAttachment(attachmentId: string, options: { output?: } let content: Buffer | undefined; - if (attachment.sha1) { - const blob = await trace.loader.resourceForSha1(attachment.sha1); + if (attachment.file) { + const blob = await trace.loader.resourceEntry(attachment.file); if (blob) content = Buffer.from(await blob.arrayBuffer()); } else if (attachment.base64) { diff --git a/packages/playwright-core/src/tools/trace/traceRequests.ts b/packages/playwright-core/src/tools/trace/traceRequests.ts index 547669d8b92ab..c2a21e82ca309 100644 --- a/packages/playwright-core/src/tools/trace/traceRequests.ts +++ b/packages/playwright-core/src/tools/trace/traceRequests.ts @@ -18,7 +18,7 @@ import path from 'path'; import { msToString } from '@isomorphic/formatUtils'; -import { loadTrace } from './traceUtils'; +import { loadTrace, formatTimestamp } from './traceUtils'; export async function traceRequests(options: { grep?: string, method?: string, status?: string, failed?: boolean }) { const trace = await loadTrace(); @@ -44,8 +44,8 @@ export async function traceRequests(options: { grep?: string, method?: string, s console.log(' No network requests'); return; } - console.log(` ${'#'.padStart(4)} ${'Method'.padEnd(8)} ${'Status'.padEnd(8)} ${'Name'.padEnd(45)} ${'Duration'.padStart(10)} ${'Size'.padStart(8)} ${'Route'.padEnd(10)}`); - console.log(` ${'─'.repeat(4)} ${'─'.repeat(8)} ${'─'.repeat(8)} ${'─'.repeat(45)} ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(10)}`); + console.log(` ${'#'.padStart(4)} ${'Start'.padEnd(9)} ${'Method'.padEnd(8)} ${'Status'.padEnd(8)} ${'Name'.padEnd(45)} ${'Duration'.padStart(10)} ${'Size'.padStart(8)} ${'Route'.padEnd(10)}`); + console.log(` ${'─'.repeat(4)} ${'─'.repeat(9)} ${'─'.repeat(8)} ${'─'.repeat(8)} ${'─'.repeat(45)} ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(10)}`); for (const { resource: r, ordinal } of indexed) { let name: string; @@ -65,7 +65,8 @@ export async function traceRequests(options: { grep?: string, method?: string, s const status = r.response.status > 0 ? String(r.response.status) : 'ERR'; const size = r.response._transferSize! > 0 ? r.response._transferSize! : r.response.bodySize; const route = formatRouteStatus(r); - console.log(` ${(ordinal + '.').padStart(4)} ${r.request.method.padEnd(8)} ${status.padEnd(8)} ${name.padEnd(45)} ${msToString(r.time).padStart(10)} ${bytesToString(size).padStart(8)} ${route.padEnd(10)}`); + const start = r._monotonicTime ? formatTimestamp(r._monotonicTime, model.startTime) : '-'; + console.log(` ${(ordinal + '.').padStart(4)} ${start.padEnd(9)} ${r.request.method.padEnd(8)} ${status.padEnd(8)} ${name.padEnd(45)} ${msToString(r.time).padStart(10)} ${bytesToString(size).padStart(8)} ${route.padEnd(10)}`); } } @@ -94,6 +95,8 @@ export async function traceRequest(requestId: string) { // General console.log(' General'); console.log(` status: ${status}`); + if (r._monotonicTime) + console.log(` start: ${formatTimestamp(r._monotonicTime, model.startTime)}`); console.log(` duration: ${msToString(r.time)}`); console.log(` size: ${bytesToString(size)}`); if (r.response.content.mimeType) @@ -116,9 +119,9 @@ export async function traceRequest(requestId: string) { // Request body if (r.request.postData) { console.log('\n Request body'); - const resource = r.request.postData._sha1 ?? r.request.postData._file; + const resource = r.request.postData._file; if (resource) { - console.log(` ${path.relative(process.cwd(), path.join(trace.model.traceUri, 'resources', resource))}`); + console.log(` ${path.relative(process.cwd(), path.join(trace.model.traceUri, resource))}`); } else { const text = r.request.postData.text.length > 2000 ? r.request.postData.text.substring(0, 2000) + '...' @@ -136,10 +139,10 @@ export async function traceRequest(requestId: string) { // Response body if (r.response.bodySize > 0) { - const resource = r.response.content._sha1 ?? r.response.content._file; + const resource = r.response.content._file; if (resource) { console.log('\n Response body'); - console.log(` ${path.relative(process.cwd(), path.join(trace.model.traceUri, 'resources', resource))}`); + console.log(` ${path.relative(process.cwd(), path.join(trace.model.traceUri, resource))}`); } else if (r.response.content.text) { const text = r.response.content.text.length > 2000 ? r.response.content.text.substring(0, 2000) + '...' diff --git a/packages/playwright-core/src/tools/trace/traceScreenshot.ts b/packages/playwright-core/src/tools/trace/traceScreenshot.ts index 0d7a9a62ba0f7..4082f17e53f06 100644 --- a/packages/playwright-core/src/tools/trace/traceScreenshot.ts +++ b/packages/playwright-core/src/tools/trace/traceScreenshot.ts @@ -38,21 +38,21 @@ export async function traceScreenshot(actionId: string, options: { output?: stri const callId = action.callId; const storage = trace.loader.storage(); const snapshotNames = ['input', 'before', 'after']; - let sha1: string | undefined; + let file: string | undefined; for (const name of snapshotNames) { const renderer = storage.snapshotByName(pageId, `${name}@${callId}`); - sha1 = renderer?.closestScreenshot(); - if (sha1) + file = renderer?.closestScreenshot(); + if (file) break; } - if (!sha1) { + if (!file) { console.error(`No screenshot found for action '${actionId}'.`); process.exitCode = 1; return; } - const blob = await trace.loader.resourceForSha1(sha1); + const blob = await trace.loader.resourceEntry(file); if (!blob) { console.error(`Screenshot resource not found.`); process.exitCode = 1; diff --git a/packages/playwright-core/src/tools/trace/traceSnapshot.ts b/packages/playwright-core/src/tools/trace/traceSnapshot.ts index 7641eddead943..b4f003f56f26c 100644 --- a/packages/playwright-core/src/tools/trace/traceSnapshot.ts +++ b/packages/playwright-core/src/tools/trace/traceSnapshot.ts @@ -84,7 +84,7 @@ export async function traceSnapshot(actionId: string, options: { name?: string, } async function serveTraceSnapshot(storage: SnapshotStorage, loader: TraceLoader, pageId: string, snapshotKey: string): Promise<{ url: string, stop: () => Promise }> { - const snapshotServer = new SnapshotServer(storage, sha1 => loader.resourceForSha1(sha1)); + const snapshotServer = new SnapshotServer(storage, file => loader.resourceEntry(file)); const httpServer = new HttpServer(); httpServer.routePrefix('/snapshot/', (request: any, response: any) => { diff --git a/packages/playwright-core/src/tools/trace/traceUtils.ts b/packages/playwright-core/src/tools/trace/traceUtils.ts index 83e5b36f22deb..4a93dd3f121bd 100644 --- a/packages/playwright-core/src/tools/trace/traceUtils.ts +++ b/packages/playwright-core/src/tools/trace/traceUtils.ts @@ -23,7 +23,7 @@ import { renderTitleForCall } from '@isomorphic/protocolFormatter'; import { resolveWithinRoot } from '@utils/fileUtils'; import { DirTraceLoaderBackend, extractTrace } from './traceParser'; -import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; +import type { ActionEntry } from '@isomorphic/trace/entries'; const traceDir = path.join('.playwright-cli', 'trace'); const cliOutputDir = '.playwright-cli'; @@ -41,7 +41,7 @@ export class LoadedTrace { this.callIdToOrdinal = ordinals.callIdToOrdinal; } - resolveActionId(actionId: string): ActionTraceEventInContext | undefined { + resolveActionId(actionId: string): ActionEntry | undefined { const ordinal = parseInt(actionId, 10); if (!isNaN(ordinal)) { const callId = this.ordinalToCallId.get(ordinal); @@ -105,7 +105,7 @@ export function formatTimestamp(ms: number, base: number): string { return `${minutes}:${seconds.toString().padStart(2, '0')}.${millis.toString().padStart(3, '0')}`; } -export function actionTitle(action: ActionTraceEventInContext): string { +export function actionTitle(action: ActionEntry): string { return renderTitleForCall({ ...action, type: action.class }) || `${action.class}.${action.method}`; } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 1b92b5acb8714..da0a75d1bf7a5 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -20479,6 +20479,9 @@ export interface APIRequestContext { }>; }>; + /** + * Tracing recorder for requests made through this API request context. + */ tracing: Tracing; [Symbol.asyncDispose](): Promise; @@ -23771,8 +23774,8 @@ export interface Tracing { * [tracing.stopHar()](https://playwright.dev/docs/api/class-tracing#tracing-stop-har) is called, or when the returned * [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. * - * Only one HAR recording can be active at a time per - * [BrowserContext](https://playwright.dev/docs/api/class-browsercontext). + * Only one HAR recording can be active at a time per [Tracing](https://playwright.dev/docs/api/class-tracing) + * instance. * * **Usage** * diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index fd4ac590611ba..a7b6455a3aaa3 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -761,6 +761,7 @@ class ArtifactsRecorder { async didCreateBrowserContext(context: BrowserContextImpl) { await this._startTraceChunkOnContextCreation(context, context.tracing); + await this._startTraceChunkOnContextCreation(context.request, context.request.tracing); } async willCloseBrowserContext(context: BrowserContextImpl) { @@ -812,6 +813,7 @@ class ArtifactsRecorder { // Collect traces/screenshots for remaining contexts. await Promise.all(leftoverContexts.map(async context => { + await this._stopTracing(context.request, context.request.tracing); await this._stopTracing(context, context.tracing); }).concat(leftoverApiRequests.map(async context => { await this._stopTracing(context, context.tracing); diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index c5b5c471a0b44..2450cbdc67ea8 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -28,7 +28,6 @@ export interface TestServerInterface { closeOnDisconnect?: boolean, interceptStdio?: boolean, watchTestDirs?: boolean, - populateDependenciesOnList?: boolean, }): Promise; ping(params: {}): Promise; diff --git a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts index 2b4096c2feff5..cd63dc5eb7006 100644 --- a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts +++ b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts @@ -153,8 +153,23 @@ async function gitCommitInfo(gitDir: string): Promise async function gitDiff(gitDir: string, ci?: CIInfo): Promise { const diffLimit = 100_000; if (ci?.prBaseHash) { - // https://git-scm.com/docs/git-fetch - await runGit(['fetch', 'origin', ci.prBaseHash, '--depth=1', '--no-auto-maintenance', '--no-auto-gc', '--no-tags', '--no-recurse-submodules'], gitDir); + // Diff against the PR base commit. Whether it is present locally depends on the checkout: + // - fetch-depth: 0 (complete clone): the base is reachable from the base branch, no fetch + // is needed. Fetching with `--depth=1` here would write `.git/shallow` and turn the + // user's complete clone shallow for the rest of the CI job. + // - fetch-depth: 1 (default on GHA): only the PR merge commit exists, the base must be + // fetched, and `--depth=1` keeps that fetch minimal in the already-shallow clone. + // - fetch-depth: N: the base is present iff it is within N commits of the merge commit. + // - Base branch force-pushed after the PR was created: the base may be missing even from + // a complete clone; fetch without `--depth` so that the clone stays complete. + const hasBaseCommit = await runGit(['cat-file', '-e', `${ci.prBaseHash}^{commit}`], gitDir) !== undefined; + if (!hasBaseCommit) { + const isShallow = await runGit(['rev-parse', '--is-shallow-repository'], gitDir) === 'true'; + const fetchArgs = ['fetch', 'origin', ci.prBaseHash, '--no-auto-maintenance', '--no-auto-gc', '--no-tags', '--no-recurse-submodules']; + if (isShallow) + fetchArgs.push('--depth=1'); + await runGit(fetchArgs, gitDir); + } const diff = await runGit(['diff', ci.prBaseHash, 'HEAD'], gitDir); if (diff) return diff.substring(0, diffLimit); diff --git a/packages/playwright/src/plugins/index.ts b/packages/playwright/src/plugins/index.ts index 4f2e878a4be6a..57963085fa99a 100644 --- a/packages/playwright/src/plugins/index.ts +++ b/packages/playwright/src/plugins/index.ts @@ -14,16 +14,12 @@ * limitations under the License. */ -import type { FullConfig, Suite } from '../../types/testReporter'; +import type { FullConfig } from '../../types/testReporter'; import type { ReporterV2 } from '../reporters/reporterV2'; export interface TestRunnerPlugin { name: string; setup?(config: FullConfig, configDir: string, reporter: ReporterV2): Promise; - populateDependencies?(): Promise; - clearCache?(): Promise; - begin?(suite: Suite): Promise; - end?(): Promise; teardown?(): Promise; } diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 6699c6292cb45..c724b6d02eb1c 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -35,7 +35,6 @@ import { createTitleMatcher, forceRegExp, removeDirAndLogToConsole } from '../ut import type { TestGroup } from '../runner/testGroups'; import type { EnvByProjectId } from './dispatcher'; -import type { TestRunnerPluginRegistration } from '../plugins'; import type { Task } from './taskRunner'; import type { FullResult, TestError } from '../../types/testReporter'; import type { Matcher, TestCaseFilter } from '../util'; @@ -164,7 +163,6 @@ export function createRunTestsTasks(config: FullConfigInternal) { return [ createPhasesTask(), createReportBeginTask(), - ...config.plugins.map(plugin => createPluginBeginTask(plugin)), createRunTestsTask(), ]; } @@ -174,8 +172,6 @@ export function createClearCacheTask(config: FullConfigInternal): Task title: 'clear cache', setup: async () => { await removeDirAndLogToConsole(cc.cacheDir); - for (const plugin of config.plugins) - await plugin.instance?.clearCache?.(); }, }; } @@ -206,18 +202,6 @@ export function createPluginSetupTasks(config: FullConfigInternal): Task { - return { - title: 'plugin begin', - setup: async testRun => { - await plugin.instance?.begin?.(testRun.rootSuite!); - }, - teardown: async () => { - await plugin.instance?.end?.(); - }, - }; -} - function createGlobalSetupTask(file: string, config: FullConfigInternal): Task { let title = 'global setup'; if (config.globalSetups.length > 1) @@ -300,7 +284,7 @@ export function createListFilesTask(): Task { }; } -export function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, populateDependencies?: boolean }): Task { +export function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean }): Task { return { title: 'load tests', setup: async (testRun, errors, softErrors) => { @@ -342,11 +326,6 @@ export function createLoadTask(mode: 'out-of-process' | 'in-process', options: { await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter); await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); - if (testRun.options.onlyChanged || options.populateDependencies) { - for (const plugin of testRun.config.plugins) - await plugin.instance?.populateDependencies?.(); - } - if (testRun.options.onlyChanged) { const changedFiles = await detectChangedTestFiles(testRun.options.onlyChanged, testRun.config.configDir); testRun.preOnlyTestFilters.push(test => changedFiles.has(test.location.file)); diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index 6088d44c7ef01..d8f8451927123 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -100,7 +100,6 @@ export class TestRunner extends EventEmitter { private _globalSetup: { cleanup: () => Promise } | undefined; private _plugins: TestRunnerPluginRegistration[] | undefined; private _watchTestDirs = false; - private _populateDependenciesOnList = false; private _startingEnv: NodeJS.ProcessEnv = {}; private _lastLoadedConfig: FullConfigInternal | undefined; @@ -117,11 +116,9 @@ export class TestRunner extends EventEmitter { async initialize(params: { watchTestDirs?: boolean; - populateDependenciesOnList?: boolean; }) { setPlaywrightTestProcessEnv(); this._watchTestDirs = !!params.watchTestDirs; - this._populateDependenciesOnList = !!params.populateDependenciesOnList; this._startingEnv = { ...process.env }; } @@ -195,7 +192,6 @@ export class TestRunner extends EventEmitter { if (!config) return { status: 'failed' }; const status = await runTasks(new TestRun(config, reporter), [ - ...createPluginSetupTasks(config), createClearCacheTask(config), ]); return { status }; @@ -251,7 +247,7 @@ export class TestRunner extends EventEmitter { }; const status = await runTasks(new TestRun(config, reporter, options), [ - createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false, populateDependencies: this._populateDependenciesOnList }), + createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false }), createReportBeginTask(), ]); return { config, status }; @@ -360,7 +356,7 @@ export class TestRunner extends EventEmitter { return { errors: errorReporter.errors(), testFiles: [] }; const status = await runTasks(new TestRun(config, reporter), [ ...createPluginSetupTasks(config), - createLoadTask('out-of-process', { failOnLoadErrors: true, filterOnly: false, populateDependencies: true }), + createLoadTask('out-of-process', { failOnLoadErrors: true, filterOnly: false }), ]); if (status !== 'passed') return { errors: errorReporter.errors(), testFiles: [] }; diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index c04413c5f9f67..fe495ae5644c4 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -135,7 +135,6 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true, - populateDependenciesOnList: true, }); await testServerConnection.runGlobalSetup({}); diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index dc66827bf8897..29b9e6aa498c9 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -34,7 +34,6 @@ export type SerializedCompilationCache = { sourceMaps: [string, string][], memoryCache: [string, MemoryCache][], fileDependencies: [string, string[]][], - externalDependencies: [string, string[]][], }; // Assumptions for the compilation cache: @@ -64,8 +63,6 @@ const sourceMaps: Map = new Map(); const memoryCache = new Map(); // Dependencies resolved by the loader. const fileDependencies = new Map>(); -// Dependencies resolved by the external bundler. -const externalDependencies = new Map>(); export function installSourceMapSupport() { Error.stackTraceLimit = 200; @@ -174,7 +171,6 @@ export function serializeCompilationCache(): SerializedCompilationCache { sourceMaps: [...sourceMaps.entries()], memoryCache: [...memoryCache.entries()], fileDependencies: [...fileDependencies.entries()].map(([filename, deps]) => ([filename, [...deps]])), - externalDependencies: [...externalDependencies.entries()].map(([filename, deps]) => ([filename, [...deps]])), }; } @@ -187,10 +183,6 @@ export function addToCompilationCache(payload: SerializedCompilationCache) { const existing = fileDependencies.get(entry[0]) || []; fileDependencies.set(entry[0], new Set([...entry[1], ...existing])); } - for (const entry of payload.externalDependencies) { - const existing = externalDependencies.get(entry[0]) || []; - externalDependencies.set(entry[0], new Set([...entry[1], ...existing])); - } } function calculateFilePathHash(filePath: string): string { @@ -237,11 +229,6 @@ export function currentFileDepsCollector(): Set | undefined { return depsCollector; } -export function setExternalDependencies(filename: string, deps: string[]) { - const depsSet = new Set(deps.filter(dep => !belongsToNodeModules(dep) && dep !== filename)); - externalDependencies.set(filename, depsSet); -} - export function fileDependenciesForTest() { return Object.fromEntries([...fileDependencies.entries()].map(entry => ( [path.basename(entry[0]), [...entry[1]].map(f => path.basename(f)).sort()] @@ -258,18 +245,6 @@ export function collectAffectedTestFiles(changedFile: string, testFileCollector: if (deps.has(changedFile)) testFileCollector.add(testFile); } - - for (const [importingFile, depsOfImportingFile] of externalDependencies) { - if (depsOfImportingFile.has(changedFile)) { - if (isTestFile(importingFile)) - testFileCollector.add(importingFile); - - for (const [testFile, depsOfTestFile] of fileDependencies) { - if (depsOfTestFile.has(importingFile)) - testFileCollector.add(testFile); - } - } - } } export function affectedTestFiles(changes: string[]): string[] { @@ -284,15 +259,7 @@ export function internalDependenciesForTestFile(filename: string): Set | } export function dependenciesForTestFile(filename: string): Set { - const result = new Set(); - for (const testDependency of fileDependencies.get(filename) || []) { - result.add(testDependency); - for (const externalDependency of externalDependencies.get(testDependency) || []) - result.add(externalDependency); - } - for (const dep of externalDependencies.get(filename) || []) - result.add(dep); - return result; + return fileDependencies.get(filename) || new Set(); } // This is only used in the dev mode, specifically excluding diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 3de5f843f6655..5eb9aac3be810 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -206,7 +206,7 @@ export class TestTracing { } for (const sourceFile of sourceFiles) { await fs.promises.readFile(sourceFile, 'utf8').then(source => { - zipFile.addBuffer(Buffer.from(source), 'resources/src@' + calculateSha1(sourceFile) + '.txt'); + zipFile.addBuffer(Buffer.from(source), 'src/' + calculateSha1(sourceFile) + path.extname(sourceFile)); }).catch(() => {}); } } @@ -225,13 +225,13 @@ export class TestTracing { continue; const sha1 = calculateSha1(content); - attachment.sha1 = sha1; + attachment.file = 'attachments/' + sha1; delete attachment.path; delete attachment.base64; if (sha1s.has(sha1)) continue; sha1s.add(sha1); - zipFile.addBuffer(content, 'resources/' + sha1); + zipFile.addBuffer(content, attachment.file); } } diff --git a/packages/trace-viewer/src/sw/main.ts b/packages/trace-viewer/src/sw/main.ts index 15a5f23d3d8c8..79d1bc4911923 100644 --- a/packages/trace-viewer/src/sw/main.ts +++ b/packages/trace-viewer/src/sw/main.ts @@ -128,7 +128,7 @@ async function innerLoadTrace(traceUri: string, progress: Progress): Promise traceLoader.resourceForSha1(sha1)); + const snapshotServer = new SnapshotServer(traceLoader.storage(), file => traceLoader.resourceEntry(file)); return { traceLoader, snapshotServer }; } @@ -155,8 +155,8 @@ async function doFetch(event: FetchEvent): Promise { const isNavigation = !!event.resultingClientId; const client = event.clientId ? await self.clients.get(event.clientId) : undefined; - if (isNavigation && !relativePath?.startsWith('/sha1/')) { - // Navigation request. Download is a /sha1/ navigation, ignore them here. + if (isNavigation && !relativePath?.startsWith('/file/')) { + // Navigation request. Download is a /file/ navigation, ignore them here. // Snapshot iframe navigation request. if (relativePath?.startsWith('/snapshot/')) { @@ -196,7 +196,7 @@ async function doFetch(event: FetchEvent): Promise { } // These commands all require a loaded trace. - if (relativePath === '/contexts' || relativePath.startsWith('/snapshotInfo/') || relativePath.startsWith('/closest-screenshot/') || relativePath.startsWith('/sha1/')) { + if (relativePath === '/contexts' || relativePath.startsWith('/snapshotInfo/') || relativePath.startsWith('/closest-screenshot/') || relativePath.startsWith('/file/')) { if (!client) return new Response('Sub-resource without a client', { status: 500 }); @@ -221,8 +221,8 @@ async function doFetch(event: FetchEvent): Promise { return loadedTrace!.snapshotServer.serveClosestScreenshot(pageOrFrameId, url.searchParams); } - if (relativePath.startsWith('/sha1/')) { - const blob = await loadedTrace!.traceLoader.resourceForSha1(relativePath.slice('/sha1/'.length)); + if (relativePath.startsWith('/file/')) { + const blob = await loadedTrace!.traceLoader.resourceEntry(relativePath.slice('/file/'.length)); if (blob) return new Response(blob, { status: 200, headers: downloadHeaders(url.searchParams) }); return new Response(null, { status: 404 }); diff --git a/packages/trace-viewer/src/third_party/devtools.ts b/packages/trace-viewer/src/third_party/devtools.ts index d4b935905b7c2..f355a327e67e4 100644 --- a/packages/trace-viewer/src/third_party/devtools.ts +++ b/packages/trace-viewer/src/third_party/devtools.ts @@ -290,7 +290,7 @@ export async function generateFetchCall(model: TraceModel | undefined, resource: } async function fetchRequestPostData(model: TraceModel | undefined, resource: Entry) { - return (model && resource.request.postData?._sha1) ? - await fetch(model.createRelativeUrl(`sha1/${resource.request.postData._sha1}`)).then(r => r.text()) + return (model && resource.request.postData?._file) ? + await fetch(model.createRelativeUrl(`file/${resource.request.postData._file}`)).then(r => r.text()) : resource.request.postData?.text; } diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 07d1d12297bda..a9c871a9b397d 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -19,11 +19,13 @@ import { clsx } from '@web/uiUtils'; import { msToString } from '@isomorphic/formatUtils'; import * as React from 'react'; import './actionList.css'; -import { stats, buildActionTree } from '@isomorphic/trace/traceModel'; +import { buildActionTree } from '@isomorphic/trace/traceModel'; import { asLocatorDescription, type Language } from '@isomorphic/locatorGenerators'; import type { TreeState } from '@web/components/treeView'; import { TreeView } from '@web/components/treeView'; -import type { ActionTraceEventInContext, ActionTreeItem } from '@isomorphic/trace/traceModel'; +import type { ActionTreeItem, TraceModel } from '@isomorphic/trace/traceModel'; +import type { ActionEntry } from '@isomorphic/trace/entries'; +import { useTraceModel } from './traceModelContext'; import type { Boundaries } from './geometry'; import { ToolbarButton } from '@web/components/toolbarButton'; import { testStatusIcon } from './testUtils'; @@ -31,15 +33,15 @@ import { getMetainfo } from '@isomorphic/protocolMetainfo'; import { formatProtocolParam } from '@isomorphic/protocolFormatter'; export interface ActionListProps { - actions: ActionTraceEventInContext[], - selectedAction: ActionTraceEventInContext | undefined, + actions: ActionEntry[], + selectedAction: ActionEntry | undefined, selectedTime: Boundaries | undefined, setSelectedTime: (time: Boundaries | undefined) => void, treeState: TreeState, setTreeState: React.Dispatch>, sdkLanguage: Language | undefined; - onSelected?: (action: ActionTraceEventInContext) => void, - onHighlighted?: (action: ActionTraceEventInContext | undefined) => void, + onSelected?: (action: ActionEntry) => void, + onHighlighted?: (action: ActionEntry | undefined) => void, revealConsole?: () => void, revealActionAttachment?(callId: string): void, isLive?: boolean, @@ -63,6 +65,7 @@ export const ActionList: React.FC = ({ isLive, actionFilterText, }) => { + const model = useTraceModel(); const { rootItem, itemMap } = React.useMemo(() => buildActionTree(actions), [actions]); const { selectedItem } = React.useMemo(() => { @@ -80,8 +83,8 @@ export const ActionList: React.FC = ({ const render = React.useCallback((item: ActionTreeItem) => { const showAttachments = !!revealActionAttachment && !!item.action.attachments?.length; - return renderAction(item.action, { sdkLanguage, revealConsole, revealActionAttachment: () => revealActionAttachment?.(item.action.callId), isLive, showDuration: true, showBadges: true, showAttachments }); - }, [isLive, revealConsole, revealActionAttachment, sdkLanguage]); + return renderAction(item.action, { model, sdkLanguage, revealConsole, revealActionAttachment: () => revealActionAttachment?.(item.action.callId), isLive, showDuration: true, showBadges: true, showAttachments }); + }, [model, isLive, revealConsole, revealActionAttachment, sdkLanguage]); const isVisible = React.useCallback((item: ActionTreeItem) => { const timeVisible = !selectedTime || !item.action || (item.action.startTime <= selectedTime.maximum && item.action.endTime >= selectedTime.minimum); @@ -129,8 +132,9 @@ export const ActionList: React.FC = ({ }; export const renderAction = ( - action: ActionTraceEvent, + action: ActionEntry, options: { + model?: TraceModel, sdkLanguage?: Language, revealConsole?: () => void, revealActionAttachment?(): void, @@ -139,8 +143,8 @@ export const renderAction = ( showBadges?: boolean, showAttachments?: boolean, }) => { - const { sdkLanguage, revealConsole, revealActionAttachment, isLive, showDuration, showBadges, showAttachments } = options; - const { errors, warnings } = stats(action); + const { model, sdkLanguage, revealConsole, revealActionAttachment, isLive, showDuration, showBadges, showAttachments } = options; + const { errors, warnings } = model?.stats(action) ?? { errors: 0, warnings: 0 }; const locator = action.params.selector ? asLocatorDescription(sdkLanguage || 'javascript', action.params.selector) : undefined; diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index f7bdd6bc18c57..ede74609510c3 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -41,7 +41,7 @@ const ExpandableAttachment: React.FunctionComponent = const ref = React.useRef(null); const isTextAttachment = isTextualMimeType(attachment.contentType); - const hasContent = !!attachment.sha1 || !!attachment.path; + const hasContent = !!attachment.file || !!attachment.path; React.useEffect(() => { if (reveal) { @@ -102,7 +102,7 @@ export const AttachmentsTab: React.FunctionComponent<{ const diffMap = new Map(); for (const attachment of attachments) { - if (!attachment.path && !attachment.sha1) + if (!attachment.path && !attachment.file) continue; const match = attachment.name.match(/^(.*)-(expected|actual|diff)\.png$/); if (match) { @@ -156,8 +156,8 @@ export const AttachmentsTab: React.FunctionComponent<{ }; export function attachmentURL(model: TraceModel | undefined, attachment: Attachment) { - if (model && attachment.sha1) - return model.createRelativeUrl(`sha1/${attachment.sha1}`) ; + if (model && attachment.file) + return model.createRelativeUrl(`file/${attachment.file}`) ; return `file?path=${encodeURIComponent(attachment.path!)}`; } @@ -169,5 +169,5 @@ function downloadURL(model: TraceModel | undefined, attachment: Attachment) { } function attachmentKey(attachment: Attachment, index: number) { - return index + '-' + (attachment.sha1 ? `sha1-` + attachment.sha1 : `path-` + attachment.path); + return index + '-' + (attachment.file ? `file-` + attachment.file : `path-` + attachment.path); } diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index fccc85722472d..2465f90a72330 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -23,11 +23,11 @@ import { CopyToClipboard } from './copyToClipboard'; import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; import { PlaceholderPanel } from './placeholderPanel'; -import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; +import type { ActionEntry } from '@isomorphic/trace/entries'; import { renderTitleForCall } from './actionList'; export const CallTab: React.FunctionComponent<{ - action: ActionTraceEventInContext | undefined, + action: ActionEntry | undefined, startTimeOffset: number, sdkLanguage: Language | undefined, }> = ({ action, startTimeOffset, sdkLanguage }) => { @@ -73,7 +73,7 @@ type Property = { text: string; }; -function renderDuration(action: ActionTraceEventInContext): string { +function renderDuration(action: ActionEntry): string { if (action.endTime) return msToString(action.endTime - action.startTime); else if (!!action.error) diff --git a/packages/trace-viewer/src/ui/filmStrip.tsx b/packages/trace-viewer/src/ui/filmStrip.tsx index 4fa56cfddc4c3..5974d9efbc44d 100644 --- a/packages/trace-viewer/src/ui/filmStrip.tsx +++ b/packages/trace-viewer/src/ui/filmStrip.tsx @@ -18,8 +18,7 @@ import './filmStrip.css'; import type { Boundaries, Size } from './geometry'; import * as React from 'react'; import { useMeasure, upperBound } from '@web/uiUtils'; -import type { PageEntry } from '@isomorphic/trace/entries'; -import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; +import type { ActionEntry, PageEntry } from '@isomorphic/trace/entries'; import { renderAction } from './actionList'; import type { Language } from '@isomorphic/locatorGenerators'; import { useTraceModel } from './traceModelContext'; @@ -27,7 +26,7 @@ import { useTraceModel } from './traceModelContext'; export type FilmStripPreviewPoint = { x: number; clientY: number; - action?: ActionTraceEventInContext; + action?: ActionEntry; sdkLanguage: Language; }; @@ -77,7 +76,7 @@ export const FilmStrip: React.FunctionComponent<{ left: Math.min(previewPoint!.x, measure.width - (previewSize ? previewSize.width : 0) - 10), }}> {previewImage && previewSize &&
- +
} {previewPoint.action &&
{renderAction(previewPoint.action, previewPoint)}
} @@ -115,7 +114,7 @@ const FilmStripLane: React.FunctionComponent<{ frames.push(
; export const LogTab: React.FunctionComponent<{ - action: ActionTraceEventInContext | undefined, + action: ActionEntry | undefined, isLive: boolean | undefined, }> = ({ action, isLive }) => { + const model = useTraceModel(); const entries = React.useMemo(() => { if (!action || !action.log.length) return []; const log = action.log; - const wallTimeOffset = action.context.wallTime - action.context.startTime; + const wallTimeOffset = model ? (model.wallTime ?? 0) - model.startTime : 0; const entries: { message: string, time: string }[] = []; for (let i = 0; i < log.length; ++i) { let time = ''; @@ -52,7 +54,7 @@ export const LogTab: React.FunctionComponent<{ }); } return entries; - }, [action, isLive]); + }, [model, action, isLive]); if (!entries.length) return ; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index dc1dadf90c497..e12fb4475bfdd 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -54,8 +54,8 @@ export const NetworkResourceDetails: React.FunctionComponent<{ if (model && resource.request.postData) { const requestContentTypeHeader = resource.request.headers.find(q => q.name.toLowerCase() === 'content-type'); const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; - if (resource.request.postData._sha1) { - const response = await fetch(model.createRelativeUrl(`sha1/${resource.request.postData._sha1}`)); + if (resource.request.postData._file) { + const response = await fetch(model.createRelativeUrl(`file/${resource.request.postData._file}`)); return { text: await response.text(), mimeType: requestContentType }; } else { return { text: resource.request.postData.text, mimeType: requestContentType }; @@ -216,10 +216,10 @@ const ResponseTab: React.FunctionComponent<{ React.useEffect(() => { const readResources = async () => { - if (model && resource.response.content._sha1) { + if (model && resource.response.content._file) { const useBase64 = resource.response.content.mimeType.includes('image'); const isFont = resource.response.content.mimeType.includes('font'); - const response = await fetch(model.createRelativeUrl(`sha1/${resource.response.content._sha1}`)); + const response = await fetch(model.createRelativeUrl(`file/${resource.response.content._file}`)); if (useBase64) { const blob = await response.blob(); const reader = new FileReader(); @@ -244,7 +244,7 @@ const ResponseTab: React.FunctionComponent<{ const formatResult = useFormattedBody(responseBody, showFormattedResponse); return
- {!resource.response.content._sha1 &&
Response body is not available for this request.
} + {!resource.response.content._file &&
Response body is not available for this request.
} {responseBody && responseBody.font && } {responseBody && responseBody.dataUrl &&
} {responseBody && responseBody.text !== undefined &&
@@ -326,9 +326,9 @@ const WebSocketMessagesTab: React.FunctionComponent<{ const indexedMessages = useAsyncMemo(async () => { if (resource._webSocketMessages) return resource._webSocketMessages.map((m, index) => ({ ...m, index, byteLength: messageByteLength(m) })); - if (model && resource.response.content._sha1) { + if (model && resource.response.content._file) { try { - const response = await fetch(model.createRelativeUrl(`sha1/${resource.response.content._sha1}`)); + const response = await fetch(model.createRelativeUrl(`file/${resource.response.content._file}`)); if (!response.ok) return []; const text = await response.text(); diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 954cc83cb3605..d7b4d1a0781be 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -20,7 +20,6 @@ import './networkTab.css'; import { NetworkResourceDetails, WebSocketResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@isomorphic/formatUtils'; import { PlaceholderPanel } from './placeholderPanel'; -import { context } 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'; @@ -221,10 +220,8 @@ function resourceContextId(model: TraceModel | undefined, resource: ResourceEntr return ''; if (resource.pageref) return model.pagerefToTitle.get(resource.pageref) || ''; - if (resource._apiRequest) { - const contextEntry = context(resource); - return (contextEntry && model.contextToTitle.get(contextEntry)) || ''; - } + if (resource._apiRequest) + return resource.contextTitle; return ''; } diff --git a/packages/trace-viewer/src/ui/playbackControl.tsx b/packages/trace-viewer/src/ui/playbackControl.tsx index a478ed23946bc..35aea8ef8556f 100644 --- a/packages/trace-viewer/src/ui/playbackControl.tsx +++ b/packages/trace-viewer/src/ui/playbackControl.tsx @@ -16,7 +16,7 @@ import { ToolbarButton } from '@web/components/toolbarButton'; import * as React from 'react'; -import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; +import type { ActionEntry } from '@isomorphic/trace/entries'; import type { Boundaries } from './geometry'; import './playbackControl.css'; @@ -43,9 +43,9 @@ export type PlaybackState = { }; export function usePlayback( - actions: ActionTraceEventInContext[], - selectedAction: ActionTraceEventInContext | undefined, - onActionSelected: (action: ActionTraceEventInContext) => void, + actions: ActionEntry[], + selectedAction: ActionEntry | undefined, + onActionSelected: (action: ActionEntry) => void, timeWindow: Boundaries | undefined, boundaries: Boundaries, ): PlaybackState { diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index 0221963c8ddd1..046c9763f6864 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -28,6 +28,12 @@ import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; import { useTraceModel } from './traceModelContext'; +function extname(file: string): string { + const basename = file.substring(Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')) + 1); + const dot = basename.lastIndexOf('.'); + return dot <= 0 ? '' : basename.substring(dot); +} + function useSources(stack: StackFrame[] | undefined, selectedFrame: number, sources: Map, rootDir?: string, fallbackLocation?: SourceLocation) { const model = useTraceModel(); return useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[], location?: SourceLocation }>(async () => { @@ -55,7 +61,11 @@ function useSources(stack: StackFrame[] | undefined, selectedFrame: number, sour } else if (source.content === undefined || (location === fallbackLocation)) { const sha1 = await calculateSha1(file); try { - let response = model ? await fetch(model.createRelativeUrl(`sha1/src@${sha1}.txt`)) : undefined; + let response = model ? await fetch(model.createRelativeUrl(`file/src/${sha1}${extname(file)}`)) : undefined; + if (!response || response.status === 404) { + // Older traces stored sources under resources/src@.txt. + response = model ? await fetch(model.createRelativeUrl(`file/resources/src@${sha1}.txt`)) : undefined; + } if (!response || response.status === 404) response = await fetch(`file?path=${encodeURIComponent(file)}`); if (response.status >= 400) diff --git a/packages/trace-viewer/src/ui/timeline.tsx b/packages/trace-viewer/src/ui/timeline.tsx index 458fae7bd7c6e..787a407aa9bc5 100644 --- a/packages/trace-viewer/src/ui/timeline.tsx +++ b/packages/trace-viewer/src/ui/timeline.tsx @@ -21,7 +21,8 @@ import * as React from 'react'; import type { Boundaries } from './geometry'; import { FilmStrip } from './filmStrip'; import type { FilmStripPreviewPoint } from './filmStrip'; -import type { ActionTraceEventInContext, TraceModel } from '@isomorphic/trace/traceModel'; +import type { TraceModel } from '@isomorphic/trace/traceModel'; +import type { ActionEntry } from '@isomorphic/trace/entries'; import './timeline.css'; import type { Language } from '@isomorphic/locatorGenerators'; import type { ActionGroup } from '@isomorphic/protocolFormatter'; @@ -29,7 +30,7 @@ import type { ActionGroup } from '@isomorphic/protocolFormatter'; export const Timeline: React.FunctionComponent<{ model: TraceModel | undefined, boundaries: Boundaries, - onSelected: (action: ActionTraceEventInContext) => void, + onSelected: (action: ActionEntry) => void, selectedTime: Boundaries | undefined, setSelectedTime: (time: Boundaries | undefined) => void, highlightedTime?: Boundaries, diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 640f6c69f0fe5..bc65849aeca60 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -21,7 +21,8 @@ import { CallTab } from './callTab'; import { LogTab } from './logTab'; import { ErrorsTab, useErrorsTabModel } from './errorsTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab'; -import type { TraceModel, SourceLocation, ActionTraceEventInContext, SourceModel } from '@isomorphic/trace/traceModel'; +import type { TraceModel, SourceLocation, SourceModel } from '@isomorphic/trace/traceModel'; +import type { ActionEntry } from '@isomorphic/trace/entries'; import { NetworkTab, useNetworkTabModel } from './networkTab'; import { SnapshotTabsView } from './snapshotTab'; import { SourceTab } from './sourceTab'; @@ -99,7 +100,7 @@ const PartitionedWorkbench: React.FunctionComponent(undefined); - const setSelectedAction = React.useCallback((action: ActionTraceEventInContext | undefined) => { + const setSelectedAction = React.useCallback((action: ActionEntry | undefined) => { setSelectedCallId(action?.callId); setRevealedErrorKey(undefined); }, [setSelectedCallId, setRevealedErrorKey]); @@ -111,7 +112,7 @@ const PartitionedWorkbench: React.FunctionComponent a.callId === highlightedCallId); }, [actions, highlightedCallId]); - const setHighlightedAction = React.useCallback((highlightedAction: ActionTraceEventInContext | undefined) => { + const setHighlightedAction = React.useCallback((highlightedAction: ActionEntry | undefined) => { setHighlightedCallId(highlightedAction?.callId); }, [setHighlightedCallId]); @@ -150,7 +151,7 @@ const PartitionedWorkbench: React.FunctionComponent { + const onActionSelected = React.useCallback((action: ActionEntry) => { setSelectedAction(action); setHighlightedAction(undefined); }, [setSelectedAction, setHighlightedAction]); diff --git a/packages/trace/src/har.ts b/packages/trace/src/har.ts index 63d81852af9ff..3711f033d55a2 100644 --- a/packages/trace/src/har.ts +++ b/packages/trace/src/har.ts @@ -140,7 +140,6 @@ export type PostData = { params: Param[]; text: string; comment?: string; - _sha1?: string; _file?: string; }; @@ -159,7 +158,6 @@ export type Content = { text?: string; encoding?: string; comment?: string; - _sha1?: string; _file?: string; }; diff --git a/packages/trace/src/snapshot.ts b/packages/trace/src/snapshot.ts index 43d002f616860..06334a2b78a50 100644 --- a/packages/trace/src/snapshot.ts +++ b/packages/trace/src/snapshot.ts @@ -33,7 +33,7 @@ export type NodeSnapshot = export type ResourceOverride = { url: string, - sha1?: string, + file?: string, ref?: number }; diff --git a/packages/trace/src/trace.ts b/packages/trace/src/trace.ts index a7d6ba58d6d49..dd10371f94c5a 100644 --- a/packages/trace/src/trace.ts +++ b/packages/trace/src/trace.ts @@ -92,7 +92,6 @@ export type ContextCreatedTraceEvent = { options: BrowserContextEventOptions, sdkLanguage?: Language, testIdAttributeName?: string, - contextId?: string, testTimeout?: number, annotations?: TraceEventAnnotation[], }; @@ -100,7 +99,7 @@ export type ContextCreatedTraceEvent = { export type ScreencastFrameTraceEvent = { type: 'screencast-frame', pageId: string, - sha1: string, + file: string, width: number, height: number, timestamp: number, @@ -113,14 +112,14 @@ export type ScreenshotTraceEvent = { type: 'screenshot', callId: string, phase: ActionPhase, - sha1: string, + file: string, }; export type AriaSnapshotTraceEvent = { type: 'aria-snapshot', callId: string, phase: ActionPhase, - sha1: string, + file: string, }; export type BeforeActionTraceEvent = { @@ -150,7 +149,7 @@ export type AfterActionTraceEventAttachment = { name: string; contentType: string; path?: string; - sha1?: string; + file?: string; base64?: string; }; diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index 7fefdbfeffe6e..15e4da5883d10 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -728,7 +728,7 @@ for (const kind of ['launchServer', 'run-server'] as const) { await browser.close(); const { resources } = await parseTraceRaw(testInfo.outputPath('trace1.zip')); - const sourceNames = Array.from(resources.keys()).filter(k => k.endsWith('.txt')); + const sourceNames = Array.from(resources.keys()).filter(k => k.startsWith('src/')); expect(sourceNames.length).toBe(1); const sourceFile = resources.get(sourceNames[0]); const thisFile = await fs.promises.readFile(__filename); diff --git a/tests/library/channels.spec.ts b/tests/library/channels.spec.ts index 04875b70953df..0276d1bd8809b 100644 --- a/tests/library/channels.spec.ts +++ b/tests/library/channels.spec.ts @@ -76,7 +76,9 @@ it('should scope context handles', async ({ browserType, server, expectScopeStat ] }, ] }, { _guid: 'debugger', objects: [] }, - { _guid: 'request-context', objects: [] }, + { _guid: 'request-context', objects: [ + { _guid: 'tracing', objects: [] }, + ] }, { _guid: 'tracing', objects: [] } ] }, ] }, @@ -164,7 +166,9 @@ it('should scope browser handles', async ({ browserType, expectScopeState }) => _guid: 'browser', objects: [ { _guid: 'browser-context', objects: [ { _guid: 'debugger', objects: [] }, - { _guid: 'request-context', objects: [] }, + { _guid: 'request-context', objects: [ + { _guid: 'tracing', objects: [] }, + ] }, { _guid: 'tracing', objects: [] }, ] }, ] @@ -208,7 +212,9 @@ it('should not generate dispatchers for subresources w/o listeners', async ({ pa ] }, { _guid: 'debugger', objects: [] }, - { _guid: 'request-context', objects: [] }, + { _guid: 'request-context', objects: [ + { _guid: 'tracing', objects: [] }, + ] }, { _guid: 'tracing', objects: [] } ] }, ] @@ -308,7 +314,12 @@ it('exposeFunction should not leak', async ({ page, expectScopeState, server }) }, { '_guid': 'request-context', - 'objects': [], + 'objects': [ + { + '_guid': 'tracing', + 'objects': [], + }, + ], }, { '_guid': 'tracing', diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 16732fc5fd531..3eccc7fc2991b 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -18,6 +18,8 @@ import { contextTest as test, expect } from '../../config/browserTest'; import { playwrightTest } from '../../config/browserTest'; +import type { Page } from 'playwright-core'; + test('should create a worker from a service worker', async ({ page, server }) => { const [worker] = await Promise.all([ page.context().waitForEvent('serviceworker'), @@ -747,6 +749,55 @@ test('should capture console.log from ServiceWorker start', async ({ context, pa expect(consoleMessage.type()).toBe('log'); }); +test.describe('WebUI navigation', () => { + const isEdge = (channel: string | undefined) => !!channel?.startsWith('msedge'); + const gotoError = (page: Page, url: string) => page.goto(url).then(() => '', e => e.message); + + test('should refuse WebUI pages that crash the browser in an isolated context', async ({ browser, page, channel }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41935' }); + test.skip(isEdge(channel), 'Edge only disallows chrome://history'); + + const hosts = ['apps', 'extensions', 'help', 'history', 'password-manager', 'settings']; + for (const url of [...hosts.map(host => `chrome://${host}`), 'chrome://extensions/', 'chrome://settings/help', 'chrome://SETTINGS', 'chrome:settings', 'chrome:///settings', 'view-source:chrome://settings']) + expect(await gotoError(page, url)).toContain(`Cannot navigate to "${url}"`); + // The refusal only matters because it keeps the browser process alive. + expect(browser.isConnected()).toBe(true); + expect(await page.evaluate(() => 1 + 1)).toBe(2); + }); + + test('should refuse WebUI pages that crash Edge in an isolated context', async ({ browser, page, channel }) => { + test.skip(!isEdge(channel), 'Edge has its own list of pages disallowed in InPrivate'); + + for (const url of ['edge://history', 'chrome://history', 'view-source:edge://history']) + expect(await gotoError(page, url)).toContain(`Cannot navigate to "${url}"`); + // Edge does allow these in InPrivate, so they must not be refused. + for (const url of ['edge://settings', 'edge://extensions']) + expect(await gotoError(page, url)).not.toContain('Cannot navigate to'); + expect(browser.isConnected()).toBe(true); + }); + + test('should navigate to WebUI pages that work in an isolated context', async ({ page, headless }) => { + test.skip(headless, 'WebUI pages are not available in headless'); + + const response = await page.goto('chrome://version'); + expect(response.status()).toBe(200); + }); + + test('should navigate to any WebUI page in a persistent context', async ({ browserType, createUserDataDir, headless }) => { + test.skip(headless, 'WebUI pages are not available in headless'); + + const context = await browserType.launchPersistentContext(await createUserDataDir()); + try { + const page = await context.newPage(); + // Committing the navigation is all this asserts - waiting for the WebUI to load is slow and beside the point. + const response = await page.goto('chrome://extensions', { waitUntil: 'commit' }); + expect(response.status()).toBe(200); + } finally { + await context.close(); + } + }); +}); + test('should fire dialogclosed event when dialog is closed out of band', async ({ page }) => { // Establish the CDP session up front: creating one while a dialog is blocking the page hangs. const client = await page.context().newCDPSession(page); diff --git a/tests/library/har-websocket.spec.ts b/tests/library/har-websocket.spec.ts index 9ef6193ff0c25..eaedb134bb475 100644 --- a/tests/library/har-websocket.spec.ts +++ b/tests/library/har-websocket.spec.ts @@ -97,7 +97,8 @@ it('should only have one websocket entry', async ({ contextFactory, server }, te expect(wsEntry._resourceType).toBe('websocket'); }); -it('should include websocket handshake headers and status', async ({ contextFactory, server }, testInfo) => { +it('should include websocket handshake headers and status', async ({ contextFactory, server, browserName, isMac, macVersion }, testInfo) => { + it.fixme(browserName === 'webkit' && isMac && macVersion >= 26, 'NWLoader does not reflect the wire handshake headers (Sec-WebSocket-Key, Connection, Upgrade) in NSURLSessionWebSocketTask.currentRequest'); server.onceWebSocketConnection(ws => { ws.on('message', () => ws.close()); }); diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 143a3186b1dc0..8d7cb41f3f7c5 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -640,10 +640,14 @@ it('should have security details', async ({ contextFactory, httpsServer, browser it.fail(browserName === 'webkit' && platform === 'win32' && channel !== 'webkit-wsl'); it.skip(isFrozenWebkit); - const { page, getLog } = await pageWithHar(contextFactory, testInfo); + const { page, context, getLog } = await pageWithHar(contextFactory, testInfo); + const apiHarPath = testInfo.outputPath('api.har'); + await context.request.tracing.startHar(apiHarPath); await page.goto(httpsServer.EMPTY_PAGE); await page.request.get(httpsServer.EMPTY_PAGE); + await context.request.tracing.stopHar(); const log = await getLog(); + expect(log.entries).toHaveLength(1); const { serverIPAddress, _serverPort: port, _securityDetails: securityDetails } = log.entries[0]; expect(serverIPAddress).toMatch(/^127\.0\.0\.1|\[::1\]/); expect(port).toBe(httpsServer.PORT); @@ -652,7 +656,9 @@ it('should have security details', async ({ contextFactory, httpsServer, browser else expect(securityDetails).toEqual({ issuer: 'playwright-test', protocol: 'TLS 1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); - expect(log.entries[1]._securityDetails).toEqual({ issuer: 'playwright-test', protocol: 'TLSv1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); + const apiLog = JSON.parse(fs.readFileSync(apiHarPath).toString()).log as Log; + expect(apiLog.entries).toHaveLength(1); + expect(apiLog.entries[0]._securityDetails).toEqual({ issuer: 'playwright-test', protocol: 'TLSv1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); }); it('should have connection details for redirects', async ({ contextFactory, server, browserName, mode }, testInfo) => { @@ -811,64 +817,25 @@ it('should have different hars for concurrent contexts', async ({ contextFactory } }); -it('should include API request', async ({ contextFactory, server }, testInfo) => { +it('should exclude API request', async ({ contextFactory, server }, testInfo) => { const { page, getLog } = await pageWithHar(contextFactory, testInfo); - const url = server.PREFIX + '/simple.json'; - const response = await page.request.post(url, { - headers: { cookie: 'a=b; c=d' }, - data: { foo: 'bar' } - }); - const responseBody = await response.body(); + await page.goto(server.EMPTY_PAGE); + await page.request.get(server.PREFIX + '/simple.json'); const log = await getLog(); - expect(log.entries.length).toBe(1); - const entry = log.entries[0]; - expect(entry.request.url).toBe(url); - expect(entry.request.method).toBe('POST'); - expect(entry.request.httpVersion).toBe('HTTP/1.1'); - expect(entry.request.cookies).toEqual([ - { - 'name': 'a', - 'value': 'b' - }, - { - 'name': 'c', - 'value': 'd' - } - ]); - expect(entry.request.headers.length).toBeGreaterThan(1); - expect(entry.request.headers.find(h => h.name.toLowerCase() === 'user-agent')).toBeTruthy(); - expect(entry.request.headers.find(h => h.name.toLowerCase() === 'content-type')?.value).toBe('application/json'); - expect(entry.request.headers.find(h => h.name.toLowerCase() === 'content-length')?.value).toBe('13'); - expect(entry.request.bodySize).toBe(13); - - expect(entry.response.status).toBe(200); - expect(entry.response.headers.find(h => h.name.toLowerCase() === 'content-type')?.value).toContain('application/json'); - expect(entry.response.content.size).toBe(15); - expect(entry.response.content.text).toBe(responseBody.toString()); - expect(entry.response.bodySize).toBe(15); - - expect(entry.time).toBeGreaterThan(0); - expect(entry.timings).toEqual(expect.objectContaining({ - blocked: -1, - connect: expect.any(Number), - dns: expect.any(Number), - receive: expect.any(Number), - send: expect.any(Number), - ssl: expect.any(Number), - wait: expect.any(Number), - })); - - expect(entry.serverIPAddress).toBeDefined(); - expect(entry._serverPort).toEqual(server.PORT); + expect(log.entries.map(entry => entry.request.url)).toEqual([server.EMPTY_PAGE]); }); it('should correctly record API request cookies with equals sign in value', async ({ contextFactory, server }, testInfo) => { - const { page, getLog } = await pageWithHar(contextFactory, testInfo); + const context = await contextFactory(); + const harPath = testInfo.outputPath('request.har'); + await context.request.tracing.startHar(harPath); const url = server.PREFIX + '/simple.json'; - await page.request.get(url, { + await context.request.get(url, { headers: { cookie: 'token=abc=xyz; other=val' }, }); - const log = await getLog(); + await context.request.tracing.stopHar(); + await context.close(); + const log = JSON.parse(fs.readFileSync(harPath).toString()).log as Log; expect(log.entries[0].request.cookies).toEqual([ { name: 'token', value: 'abc=xyz' }, { name: 'other', value: 'val' }, @@ -876,13 +843,17 @@ it('should correctly record API request cookies with equals sign in value', asyn }); it('should respect minimal mode for API Requests', async ({ contextFactory, server }, testInfo) => { - const { page, getLog } = await pageWithHar(contextFactory, testInfo, { mode: 'minimal' }); + const context = await contextFactory(); + const harPath = testInfo.outputPath('request.har'); + await context.request.tracing.startHar(harPath, { mode: 'minimal' }); const url = server.PREFIX + '/simple.json'; - await page.request.post(url, { + await context.request.post(url, { headers: { cookie: 'a=b; c=d' }, data: { foo: 'bar' } }); - const { entries } = await getLog(); + await context.request.tracing.stopHar(); + await context.close(); + const { entries } = JSON.parse(fs.readFileSync(harPath).toString()).log as Log; expect(entries).toHaveLength(1); const [entry] = entries; expect(entry.timings).toEqual({ receive: -1, send: -1, wait: -1 }); @@ -895,12 +866,16 @@ it('should respect minimal mode for API Requests', async ({ contextFactory, serv it('should include redirects from API request', async ({ contextFactory, server }, testInfo) => { server.setRedirect('/redirect-me', '/simple.json'); - const { page, getLog } = await pageWithHar(contextFactory, testInfo); - await page.request.post(server.PREFIX + '/redirect-me', { + const context = await contextFactory(); + const harPath = testInfo.outputPath('request.har'); + await context.request.tracing.startHar(harPath); + await context.request.post(server.PREFIX + '/redirect-me', { headers: { cookie: 'a=b; c=d' }, data: { foo: 'bar' } }); - const log = await getLog(); + await context.request.tracing.stopHar(); + await context.close(); + const log = JSON.parse(fs.readFileSync(harPath).toString()).log as Log; expect(log.entries.length).toBe(2); const [redirect, json] = log.entries; expect(redirect.request.url).toBe(server.PREFIX + '/redirect-me'); @@ -971,7 +946,8 @@ it('should support HAR larger than 512MB', async ({ contextFactory, server, brow it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36707' }); const harPath = testInfo.outputPath('test.har'); - const context = await contextFactory({ recordHar: { path: harPath } }); + const context = await contextFactory(); + await context.request.tracing.startHar(harPath); // 30 x 20MB textual responses push the HAR JSON past V8's ~512MB max // string length. Each body still fits in a single string; only the @@ -983,6 +959,7 @@ it('should support HAR larger than 512MB', async ({ contextFactory, server, brow }); for (let i = 0; i < 30; i++) await context.request.get(`${server.PREFIX}/large`); + await context.request.tracing.stopHar(); await context.close(); const stats = fs.statSync(harPath); @@ -1073,6 +1050,64 @@ it.describe('tracing.startHar', () => { expect(log.entries[0].request.bodySize).toBe(-1); }); + it('should record a HAR for a context APIRequestContext', async ({ contextFactory, server }, testInfo) => { + const context = await contextFactory(); + const harPath = testInfo.outputPath('request.har'); + await context.request.tracing.startHar(harPath); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + const url = server.PREFIX + '/simple.json'; + const response = await context.request.post(url, { + headers: { cookie: 'a=b; c=d' }, + data: { foo: 'bar' } + }); + const responseBody = await response.body(); + await context.request.tracing.stopHar(); + await context.close(); + + const log = JSON.parse(fs.readFileSync(harPath).toString()).log as Log; + expect(log.entries).toHaveLength(1); + const entry = log.entries[0]; + expect(entry.request.url).toBe(url); + expect(entry.request.method).toBe('POST'); + expect(entry.request.httpVersion).toBe('HTTP/1.1'); + expect(entry.request.cookies).toEqual([ + { + 'name': 'a', + 'value': 'b' + }, + { + 'name': 'c', + 'value': 'd' + } + ]); + expect(entry.request.headers.length).toBeGreaterThan(1); + expect(entry.request.headers.find(h => h.name.toLowerCase() === 'user-agent')).toBeTruthy(); + expect(entry.request.headers.find(h => h.name.toLowerCase() === 'content-type')?.value).toBe('application/json'); + expect(entry.request.headers.find(h => h.name.toLowerCase() === 'content-length')?.value).toBe('13'); + expect(entry.request.bodySize).toBe(13); + + expect(entry.response.status).toBe(200); + expect(entry.response.headers.find(h => h.name.toLowerCase() === 'content-type')?.value).toContain('application/json'); + expect(entry.response.content.size).toBe(15); + expect(entry.response.content.text).toBe(responseBody.toString()); + expect(entry.response.bodySize).toBe(15); + + expect(entry.time).toBeGreaterThan(0); + expect(entry.timings).toEqual(expect.objectContaining({ + blocked: -1, + connect: expect.any(Number), + dns: expect.any(Number), + receive: expect.any(Number), + send: expect.any(Number), + ssl: expect.any(Number), + wait: expect.any(Number), + })); + + expect(entry.serverIPAddress).toBeDefined(); + expect(entry._serverPort).toEqual(server.PORT); + }); + it('should include pages', async ({ contextFactory, server }, testInfo) => { const context = await contextFactory(); const harPath = testInfo.outputPath('tracing.har'); @@ -1150,9 +1185,11 @@ it.describe('tracing.startHar', () => { const log = JSON.parse(fs.readFileSync(harPath).toString()).log as Log; const styleEntry = log.entries.find(e => e.request.url.endsWith('/one-style.css'))!; - const sha1 = (styleEntry.response.content as any)._file as string; - expect(sha1).toBeTruthy(); - const resourcePath = path.join(resourcesDir, sha1); + const file = (styleEntry.response.content as any)._file as string; + expect(file).toBeTruthy(); + // _file is relative to the har file directory. + const resourcePath = path.join(path.dirname(harPath), file); + expect(resourcePath.startsWith(resourcesDir + path.sep)).toBe(true); expect(fs.existsSync(resourcePath)).toBe(true); expect(fs.readFileSync(resourcePath).toString()).toContain('pink'); }); diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index 85466e461ff1c..d0e2464559be0 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -58,10 +58,10 @@ test('should collect trace with resources, but no js', async ({ context, page, s expect(events.some(e => e.type === 'screencast-frame')).toBeTruthy(); const style = events.find(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css')); expect(style).toBeTruthy(); - expect(style.snapshot.response.content._sha1).toBeTruthy(); + expect(style.snapshot.response.content._file).toBeTruthy(); const script = events.find(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('script.js')); expect(script).toBeTruthy(); - expect(script.snapshot.response.content._sha1).toBe(undefined); + expect(script.snapshot.response.content._file).toBe(undefined); }); test('should use the correct title for event driven callbacks', async ({ context, page, server }, testInfo) => { @@ -130,7 +130,8 @@ test('should collect action screenshots', async ({ context, page, server }, test const screenshots = events.filter(e => e.type === 'screenshot' && e.callId === clickCallId); expect(screenshots.map(e => e.phase)).toEqual(['before', 'action', 'after']); for (const screenshot of screenshots) { - const buffer = resources.get('resources/' + screenshot.sha1); + expect(screenshot.file).toBe(`screenshots/${clickCallId}-${screenshot.phase}.png`); + const buffer = resources.get(screenshot.file); expect(PNG.sync.read(buffer).width).toBeGreaterThan(0); } }); @@ -147,8 +148,8 @@ test('should collect aria snapshots', async ({ context, page, server }, testInfo expect(ariaSnapshots.map(e => e.phase)).toEqual(['before', 'action', 'after']); const hasButton = nodes => nodes.some(node => typeof node === 'object' && (node.role === 'button' && node.name === 'Click target' || hasButton(node.children ?? []))); for (const ariaSnapshot of ariaSnapshots) { - expect(ariaSnapshot.sha1).toMatch(/\.json$/); - const snapshot = JSON.parse(resources.get('resources/' + ariaSnapshot.sha1).toString()); + expect(ariaSnapshot.file).toBe(`aria/${clickCallId}-${ariaSnapshot.phase}.json`); + const snapshot = JSON.parse(resources.get(ariaSnapshot.file).toString()); expect(hasButton(snapshot)).toBe(true); } }); @@ -207,16 +208,31 @@ test('should exclude internal pages', async ({ browserName, context, page, serve expect(pageIds.size).toBe(1); }); -test('should include context API requests', async ({ context, page, server }, testInfo) => { +test('should record context API request trace independently', async ({ context, page, server }, testInfo) => { + const browserTracePath = testInfo.outputPath('browser-trace.zip'); + const apiTracePath = testInfo.outputPath('api-trace.zip'); + const apiURL = server.PREFIX + '/simple.json'; + expect(context.request.tracing).not.toBe(context.tracing); + await context.tracing.start({ snapshots: true }); - await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } }); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip')); - expect(actions).toContain('POST "/simple.json"'); - const harEntry = events.find(e => e.type === 'resource-snapshot'); - expect(harEntry).toBeTruthy(); - expect(harEntry.snapshot.request.url).toBe(server.PREFIX + '/simple.json'); - expect(harEntry.snapshot.response.status).toBe(200); + await context.request.tracing.start({ snapshots: true }); + await page.goto(server.PREFIX + '/one-style.html'); + await page.request.post(apiURL, { data: { foo: 'bar' } }); + await context.tracing.stop({ path: browserTracePath }); + await context.request.tracing.stop({ path: apiTracePath }); + + 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('/one-style.html'))).toBe(true); + + const apiTrace = await parseTraceRaw(apiTracePath); + expect(apiTrace.actions).toContain('POST "/simple.json"'); + expect(apiTrace.actions).not.toContain('Navigate to "/one-style.html"'); + 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]); }); test('should collect two traces', async ({ context, page, server }, testInfo) => { @@ -336,7 +352,7 @@ test('should not include trace resources from the previous chunks', async ({ con expect(names.filter(n => n.endsWith('.html')).length).toBe(1); jpegs = names.filter(n => n.endsWith('.jpeg')); // 1 source file for the test. - expect(names.filter(n => n.endsWith('.txt')).length).toBe(1); + expect(names.filter(n => n.startsWith('src/')).length).toBe(1); } { @@ -347,7 +363,7 @@ test('should not include trace resources from the previous chunks', async ({ con // screenshots from the previous chunk should not be preserved. expect(names.filter(n => jpegs.includes(n)).length).toBe(0); // 0 source files for the second test. - expect(names.filter(n => n.endsWith('.txt')).length).toBe(0); + expect(names.filter(n => n.startsWith('src/')).length).toBe(0); } }); @@ -382,8 +398,9 @@ test('should collect sources', async ({ context, page, server }, testInfo) => { await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') }); const { resources } = await parseTraceRaw(testInfo.outputPath('trace1.zip')); - const sourceNames = Array.from(resources.keys()).filter(k => k.endsWith('.txt')); + const sourceNames = Array.from(resources.keys()).filter(k => k.startsWith('src/')); expect(sourceNames.length).toBe(1); + expect(sourceNames[0]).toMatch(/^src\/[0-9a-f]{40}\.ts$/); const sourceFile = resources.get(sourceNames[0]); const thisFile = await fs.promises.readFile(__filename); expect(sourceFile).toEqual(thisFile); @@ -398,6 +415,8 @@ test('should record network failures', async ({ context, page, server }, testInf const { events } = await parseTraceRaw(testInfo.outputPath('trace1.zip')); const requestEvent = events.find(e => e.type === 'resource-snapshot' && !!e.snapshot.response._failureText); expect(requestEvent).toBeTruthy(); + expect(requestEvent.snapshot._monotonicTime).toBeGreaterThan(0); + expect(requestEvent.snapshot.time).toBeGreaterThanOrEqual(0); }); test('should not crash when browser closes mid-trace', async ({ browserType, server }, testInfo) => { @@ -502,14 +521,14 @@ for (const params of [ for (const frame of frames) { expect.soft(frame.width).toBe(params.width); expect.soft(frame.height).toBe(params.height); - const buffer = resources.get('resources/' + frame.sha1); + const buffer = resources.get(frame.file); const image = jpegjs.decode(buffer); expect.soft(image.width).toBe(previewWidth); expect.soft(image.height).toBe(previewHeight); } const frame = frames[frames.length - 1]; // pick last frame. - const buffer = resources.get('resources/' + frame.sha1); + const buffer = resources.get(frame.file); const image = jpegjs.decode(buffer); expect(image.data.byteLength).toBe(previewWidth * previewHeight * 4); expectRed(image.data, previewWidth * previewHeight * 4 / 2 + previewWidth * 4 / 2); // center is red @@ -751,7 +770,7 @@ test('should store postData for global request', async ({ request, server }, tes const actions = trace.events.filter(e => e.type === 'resource-snapshot'); expect(actions).toHaveLength(1); const req = actions[0].snapshot.request; - expect(req.postData?._sha1).toBeTruthy(); + expect(req.postData?._file).toBeTruthy(); expect(req).toEqual(expect.objectContaining({ method: 'POST', url diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index df431af1e858a..82ce623a12e86 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -761,7 +761,7 @@ it.describe('screencast', () => { const { events, resources } = await parseTraceRaw(traceFile); const frame = events.filter(e => e.type === 'screencast-frame').pop(); - const buffer = resources.get('resources/' + frame.sha1); + const buffer = resources.get(frame.file); const image = jpegjs.decode(buffer); expect(image.width).toBe(size.width); expect(image.height).toBe(size.height); diff --git a/tests/mcp/trace-cli-fixtures.ts b/tests/mcp/trace-cli-fixtures.ts index 1c39e81887dac..0f05b8f492fd5 100644 --- a/tests/mcp/trace-cli-fixtures.ts +++ b/tests/mcp/trace-cli-fixtures.ts @@ -108,6 +108,10 @@ export const test = baseTest // Fetch await page.evaluate(() => fetch('/feedback', { method: 'POST', body: 'What a great product!' }).then(res => res.text())); + // Aborted fetch + await page.route('**/blocked', route => route.abort()); + await page.evaluate(() => fetch('/blocked').catch(() => {})); + // Navigate to another page await page.locator('a').click(); diff --git a/tests/mcp/trace-cli.spec.ts b/tests/mcp/trace-cli.spec.ts index 7b886ad1f6a71..49d14c5af67a6 100644 --- a/tests/mcp/trace-cli.spec.ts +++ b/tests/mcp/trace-cli.spec.ts @@ -93,6 +93,18 @@ test('trace requests shows requests with ordinals', async ({ runTraceCli }) => { expect(stdout).toMatch(/\d+\./); }); +test('trace requests shows start times and aborted request durations', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['requests']); + expect(exitCode).toBe(0); + expect(stdout).toContain('Start'); + // Every request row carries a start timestamp in the `trace actions` Time format. + expect(stdout).toMatch(/\d+\.\s+\d+:\d{2}\.\d{3}\s/); + // The aborted request has a recorded duration rather than '-'. + const abortedRow = stdout.split('\n').find(line => line.includes('aborted'))!; + expect(abortedRow).toContain('blocked'); + expect(abortedRow).toMatch(/\s\d+(\.\d+)?m?s\s/); +}); + test('trace requests --method filters', async ({ runTraceCli }) => { const { stdout, exitCode } = await runTraceCli(['requests', '--method', 'GET']); expect(exitCode).toBe(0); @@ -105,6 +117,7 @@ test('trace request shows details', async ({ runTraceCli }) => { expect(exitCode).toBe(0); expect(stdout).toContain('General'); expect(stdout).toContain('status:'); + expect(stdout).toMatch(/start:\s+\d+:\d{2}\.\d{3}/); expect(stdout).toContain('Request headers'); expect(stdout).toContain('Response headers'); }); diff --git a/tests/mcp/tracing.spec.ts b/tests/mcp/tracing.spec.ts index 9f9e0edbdefb6..8aa4f23e1aaa9 100644 --- a/tests/mcp/tracing.spec.ts +++ b/tests/mcp/tracing.spec.ts @@ -45,6 +45,7 @@ test('check that trace is saved with browser_start_tracing', async ({ startClien const files = await fs.promises.readdir(path.join(outputDir, 'traces')); expect(files).toEqual([ 'resources', + 'screencast', expect.stringMatching(/trace-\d+\.network/), expect.stringMatching(/trace-\d+\.stacks/), expect.stringMatching(/trace-\d+\.trace/), @@ -78,6 +79,7 @@ test('check that trace is saved with browser_start_tracing (no output dir)', asy const files = await fs.promises.readdir(testInfo.outputPath('.playwright-mcp', 'traces')); expect(files).toEqual([ 'resources', + 'screencast', expect.stringMatching(/trace-\d+\.network/), expect.stringMatching(/trace-\d+\.stacks/), expect.stringMatching(/trace-\d+\.trace/), diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index 8074ec53c0ae2..6e65a81d1029d 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -344,8 +344,9 @@ it('should report if request was fromServiceWorker', async ({ page, server, isAn } }); -it('should return body for prefetch script', async ({ page, server, browserName }) => { +it('should return body for prefetch script', async ({ page, server, browserName, browserMajorVersion }) => { it.skip(browserName === 'webkit', 'No prefetch in WebKit: https://caniuse.com/link-rel-prefetch'); + it.skip(browserName === 'chromium' && browserMajorVersion < 138, 'Requires Sec-Purpose header, shipped in Chrome 138'); const [response] = await Promise.all([ page.waitForResponse('**/prefetch.js'), page.goto(server.PREFIX + '/prefetch.html') diff --git a/tests/page/page-network-sizes.spec.ts b/tests/page/page-network-sizes.spec.ts index 3157b4dffb03e..bbbb3508ea453 100644 --- a/tests/page/page-network-sizes.spec.ts +++ b/tests/page/page-network-sizes.spec.ts @@ -93,8 +93,9 @@ it('should have the correct responseBodySize for chunked request', async ({ page const sizes = await response.request().sizes(); // The actual file size is 5100 bytes. The extra 75 bytes are coming from the chunked encoding headers and end bytes. if (browserName === 'webkit') - // It should be 5175 there. On the actual network response, the body has a size of 5175. - expect(sizes.responseBodySize).toBe(5173); + // WebKit on macOS reports 5173 with the legacy CFNetwork loader (builds <= 2346) and the + // correct 5175 with NWLoader. TODO: expect 5175 once the NWLoader-based build ships. + expect([5173, 5175]).toContain(sizes.responseBodySize); else expect(sizes.responseBodySize).toBe(5175); }); diff --git a/tests/playwright-test/playwright.connect.spec.ts b/tests/playwright-test/playwright.connect.spec.ts index 71aa32b0cc83a..b31e15edfd595 100644 --- a/tests/playwright-test/playwright.connect.spec.ts +++ b/tests/playwright-test/playwright.connect.spec.ts @@ -210,9 +210,9 @@ test('should record trace', async ({ runInlineTest }) => { expect(result.passed).toBe(1); expect(result.failed).toBe(1); - // A single tracing artifact should be created. We see it in the logs twice: + // One tracing artifact should be created for each tracing stream. We see each in the logs twice: // as a regular message and wrapped inside a jsonPipe. - expect(countTimes(result.output, `"type":"Artifact","initializer"`)).toBe(2); + expect(countTimes(result.output, `"type":"Artifact","initializer"`)).toBe(4); expect(fs.existsSync(test.info().outputPath('test-results', 'a-pass', 'trace.zip'))).toBe(false); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 810be371d828a..6d0eb8f2ab0c7 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -188,9 +188,11 @@ test('should not mixup network files between contexts', async ({ runInlineTest, test.beforeAll(async ({ browser }) => { page1 = await browser.newPage(); await page1.goto("${server.EMPTY_PAGE}"); + await page1.request.get("${server.PREFIX}/simple.json?context=1"); page2 = await browser.newPage(); await page2.goto("${server.EMPTY_PAGE}"); + await page2.request.get("${server.PREFIX}/simple.json?context=2"); }); test.afterAll(async () => { @@ -200,12 +202,43 @@ test('should not mixup network files between contexts', async ({ runInlineTest, test('example', async ({ page }) => { await page.goto("${server.EMPTY_PAGE}"); + await page.request.get("${server.PREFIX}/simple.json?context=3"); }); `, }, { workers: 1, timeout: 15000 }); expect(result.exitCode).toEqual(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-example', 'trace.zip'))).toBe(true); + const tracePath = testInfo.outputPath('test-results', 'a-example', 'trace.zip'); + const { resources } = await parseTraceRaw(tracePath); + const traceEntries = [...resources].filter(([name]) => name.endsWith('.trace')).map(([name, content]) => ({ + prefix: name.slice(0, -'.trace'.length), + contextOptions: JSON.parse(content.toString().split('\n')[0]), + })).filter(entry => entry.contextOptions.origin === 'library'); + // Each of the 3 browser contexts and 3 api request contexts produces + // a trace chunk per test phase it was recording during. + const browserTraces = traceEntries.filter(entry => entry.contextOptions.browserName); + const apiTraces = traceEntries.filter(entry => !entry.contextOptions.browserName); + expect(browserTraces.length).toBeGreaterThanOrEqual(3); + expect(apiTraces.length).toBeGreaterThanOrEqual(3); + for (const entry of browserTraces) { + const network = resources.get(entry.prefix + '.network')!.toString(); + expect(network).not.toContain('?context='); + } + const apiURLs = [ + server.PREFIX + '/simple.json?context=1', + server.PREFIX + '/simple.json?context=2', + server.PREFIX + '/simple.json?context=3', + ]; + // Api request context network files are chunk-specific, so each of the requests + // must show up in exactly one network file. + const apiURLsByTrace = apiTraces.map(entry => { + const network = resources.get(entry.prefix + '.network')!.toString(); + return apiURLs.filter(url => network.includes(url)); + }); + 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); }); test('should save sources when requested', async ({ runInlineTest }, testInfo) => { @@ -226,7 +259,7 @@ test('should save sources when requested', async ({ runInlineTest }, testInfo) = }, { workers: 1 }); expect(result.exitCode).toEqual(0); const { resources } = await parseTraceRaw(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); - expect([...resources.keys()].filter(name => name.startsWith('resources/src@'))).toHaveLength(1); + expect([...resources.keys()].filter(name => name.startsWith('src/'))).toHaveLength(1); }); test('should not save sources when not requested', async ({ runInlineTest }, testInfo) => { @@ -250,7 +283,7 @@ test('should not save sources when not requested', async ({ runInlineTest }, tes }, { workers: 1 }); expect(result.exitCode).toEqual(0); const { resources } = await parseTraceRaw(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); - expect([...resources.keys()].filter(name => name.startsWith('resources/src@'))).toHaveLength(0); + expect([...resources.keys()].filter(name => name.startsWith('src/'))).toHaveLength(0); }); test('should work in serial mode', async ({ runInlineTest }, testInfo) => { @@ -522,10 +555,10 @@ test('should include attachments by default', async ({ runInlineTest, server }, expect(trace.model.actions[1].attachments).toEqual([{ name: 'foo', contentType: 'text/plain', - sha1: expect.any(String), + file: expect.any(String), }]); const { resources } = await parseTraceRaw(tracePath); - expect([...resources.keys()]).toContain(`resources/${trace.model.actions[1].attachments[0].sha1}`); + expect([...resources.keys()]).toContain(trace.model.actions[1].attachments[0].file); }); test('should opt out of attachments', async ({ runInlineTest, server }, testInfo) => { @@ -553,7 +586,7 @@ test('should opt out of attachments', async ({ runInlineTest, server }, testInfo ]); expect(trace.model.actions[1].attachments).toEqual(undefined); const { resources } = await parseTraceRaw(tracePath); - expect([...resources.keys()].filter(f => f.startsWith('resources/') && !f.startsWith('resources/src@'))).toHaveLength(0); + expect([...resources.keys()].filter(f => f.startsWith('attachments/') || f.startsWith('resources/'))).toHaveLength(0); }); test('should record with custom page fixture', async ({ runInlineTest }, testInfo) => { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 3c0a31e733247..63cbacea821cf 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -3192,6 +3192,68 @@ for (const useIntermediateMergeReport of [true, false] as const) { expect(prompt, 'contains diff').toContain(`+ expect(2).toBe(3);`); }); + test('should not turn complete clone shallow when capturing diff', async ({ runInlineTest, writeFiles }, testInfo) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42203' }); + const files = { + 'playwright.config.ts': `export default {}`, + 'example.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('sample', async ({}) => { expect(2).toBe(2); }); + `, + }; + const baseDir = await writeFiles(files); + await initGitRepo(baseDir); + const originDir = testInfo.outputPath('origin.git'); + await execGit(baseDir, ['clone', '--bare', baseDir, originDir]); + await execGit(originDir, ['config', 'uploadpack.allowAnySHA1InWant', 'true']); + await execGit(baseDir, ['remote', 'add', 'origin', originDir]); + const { stdout: baseSha } = await spawnAsync('git', ['rev-parse', 'HEAD~1'], { stdio: 'pipe', cwd: baseDir }); + + const result = await runInlineTest({}, { reporter: 'dot' }, { + PLAYWRIGHT_HTML_OPEN: 'never', + ...(await ghaPullRequestEnv(baseDir, baseSha.trim())), + }); + + expect(result.exitCode).toBe(0); + expect(result.report.config.metadata.gitDiff).toContain('example.spec.ts'); + const { stdout: isShallow } = await spawnAsync('git', ['rev-parse', '--is-shallow-repository'], { stdio: 'pipe', cwd: baseDir }); + expect(isShallow.trim()).toBe('false'); + }); + + test('should fetch missing pull request base commit when capturing diff', async ({ runInlineTest, writeFiles }, testInfo) => { + const files = { + 'playwright.config.ts': `export default {}`, + 'example.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('sample', async ({}) => { expect(2).toBe(2); }); + `, + }; + const baseDir = await writeFiles(files); + await initGitRepo(baseDir); + const originDir = testInfo.outputPath('origin.git'); + await execGit(baseDir, ['clone', '--bare', baseDir, originDir]); + await execGit(originDir, ['config', 'uploadpack.allowAnySHA1InWant', 'true']); + await execGit(baseDir, ['remote', 'add', 'origin', originDir]); + + const otherDir = testInfo.outputPath('other'); + await execGit(baseDir, ['clone', originDir, otherDir]); + await fs.promises.writeFile(path.join(otherDir, 'baseline.txt'), 'baseline'); + await execGit(otherDir, ['add', 'baseline.txt']); + await execGit(otherDir, ['-c', 'user.email=shakespeare@example.local', '-c', 'user.name=William', 'commit', '-m', 'baseline']); + const { stdout: baseSha } = await spawnAsync('git', ['rev-parse', 'HEAD'], { stdio: 'pipe', cwd: otherDir }); + await execGit(otherDir, ['push', 'origin', 'HEAD:refs/heads/baseline']); + + const result = await runInlineTest({}, { reporter: 'dot' }, { + PLAYWRIGHT_HTML_OPEN: 'never', + ...(await ghaPullRequestEnv(baseDir, baseSha.trim())), + }); + + expect(result.exitCode).toBe(0); + expect(result.report.config.metadata.gitDiff).toContain('baseline.txt'); + const { stdout: isShallow } = await spawnAsync('git', ['rev-parse', '--is-shallow-repository'], { stdio: 'pipe', cwd: baseDir }); + expect(isShallow.trim()).toBe('false'); + }); + test('should include snapshot when page wasnt closed', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'example.spec.ts': ` @@ -3660,13 +3722,13 @@ function ghaCommitEnv() { }; } -async function ghaPullRequestEnv(baseDir: string) { +async function ghaPullRequestEnv(baseDir: string, baseSha: string = 'main') { const eventPath = path.join(baseDir, 'event.json'); await fs.promises.writeFile(eventPath, JSON.stringify({ pull_request: { title: 'My PR', number: 42, - base: { sha: 'main' }, + base: { sha: baseSha }, }, })); return {