diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index 1b52ab5658..5bf9c3e7ae 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -259,7 +259,6 @@ export class DispatcherConnection { method, params: params || {}, log: [], - snapshots: [] }; if (sdkObject && params?.info?.waitId) { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index b18fce5e92..ed92d79bde 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1391,7 +1391,7 @@ export class Frame extends SdkObject { const injected = await context.injectedScript(); progress.throwIfAborted(); - const { log, matches, received, missingRecevied } = await injected.evaluate(async (injected, { info, options, snapshotName }) => { + const { log, matches, received, missingRecevied } = await injected.evaluate(async (injected, { info, options, callId }) => { const elements = info ? injected.querySelectorAll(info.parsed, document) : []; const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); let log = ''; @@ -1401,10 +1401,10 @@ export class Frame extends SdkObject { throw injected.strictModeViolationError(info!.parsed, elements); else if (elements.length) log = ` locator resolved to ${injected.previewNode(elements[0])}`; - if (snapshotName) - injected.markTargetElements(new Set(elements), snapshotName); + if (callId) + injected.markTargetElements(new Set(elements), callId); return { log, ...(await injected.expect(elements[0], options, elements)) }; - }, { info, options, snapshotName: progress.metadata.afterSnapshot }); + }, { info, options, callId: metadata.id }); if (log) progress.log(log); @@ -1552,16 +1552,16 @@ export class Frame extends SdkObject { progress.throwIfAborted(); if (!resolved) return continuePolling; - const { log, success, value } = await resolved.injected.evaluate((injected, { info, callbackText, taskData, snapshotName }) => { + const { log, success, value } = await resolved.injected.evaluate((injected, { info, callbackText, taskData, callId }) => { const callback = injected.eval(callbackText) as ElementCallback; const element = injected.querySelector(info.parsed, document, info.strict); if (!element) return { success: false }; const log = ` locator resolved to ${injected.previewNode(element)}`; - if (snapshotName) - injected.markTargetElements(new Set([element]), snapshotName); + if (callId) + injected.markTargetElements(new Set([element]), callId); return { log, success: true, value: callback(injected, element, taskData as T) }; - }, { info: resolved.info, callbackText, taskData, snapshotName: progress.metadata.afterSnapshot }); + }, { info: resolved.info, callbackText, taskData, callId: progress.metadata.id }); if (log) progress.log(log); diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 0cd494830b..3a1017dd59 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1087,14 +1087,14 @@ export class InjectedScript { } } - markTargetElements(markedElements: Set, snapshotName: string) { + markTargetElements(markedElements: Set, callId: string) { for (const e of this._markedTargetElements) { if (!markedElements.has(e)) e.removeAttribute('__playwright_target__'); } for (const e of markedElements) { if (!this._markedTargetElements.has(e)) - e.setAttribute('__playwright_target__', snapshotName); + e.setAttribute('__playwright_target__', callId); } this._markedTargetElements = markedElements; } diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index d58ed444ea..147d188988 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -112,7 +112,6 @@ export function serverSideCallMetadata(): CallMetadata { method: '', params: {}, log: [], - snapshots: [], isServerSide: true, }; } diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 2c3d50c018..28e92e7968 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -575,7 +575,6 @@ class ContextRecorder extends EventEmitter { method: action, params, log: [], - snapshots: [], }; this._generator.willPerformAction(actionInContext); diff --git a/packages/playwright-core/src/server/trace/recorder/snapshotter.ts b/packages/playwright-core/src/server/trace/recorder/snapshotter.ts index f99133699f..27e2165739 100644 --- a/packages/playwright-core/src/server/trace/recorder/snapshotter.ts +++ b/packages/playwright-core/src/server/trace/recorder/snapshotter.ts @@ -104,14 +104,14 @@ export class Snapshotter { eventsHelper.removeEventListeners(this._eventListeners); } - async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise { + async captureSnapshot(page: Page, callId: string, snapshotName: string, element?: ElementHandle): Promise { // Prepare expression synchronously. const expression = `window["${this._snapshotStreamer}"].captureSnapshot(${JSON.stringify(snapshotName)})`; // In a best-effort manner, without waiting for it, mark target element. - element?.callFunctionNoReply((element: Element, snapshotName: string) => { - element.setAttribute('__playwright_target__', snapshotName); - }, snapshotName); + element?.callFunctionNoReply((element: Element, callId: string) => { + element.setAttribute('__playwright_target__', callId); + }, callId); // In each frame, in a non-stalling manner, capture the snapshots. const snapshots = page.frames().map(async frame => { @@ -121,6 +121,7 @@ export class Snapshotter { return; const snapshot: FrameSnapshot = { + callId, snapshotName, pageId: page.guid, frameId: frame.guid, diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 8ba59907db..37fe87836f 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -71,7 +71,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps private _snapshotter?: Snapshotter; private _harTracer: HarTracer; private _screencastListeners: RegisteredListener[] = []; - private _pendingCalls = new Map, actionSnapshot?: Promise, afterSnapshot?: Promise }>(); private _context: BrowserContext | APIRequestContext; private _state: RecordingState | undefined; private _isStopping = false; @@ -249,19 +248,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps if (this._state?.options.screenshots) this._stopScreencast(); - for (const { sdkObject, metadata, beforeSnapshot, actionSnapshot, afterSnapshot } of this._pendingCalls.values()) { - await Promise.all([beforeSnapshot, actionSnapshot, afterSnapshot]); - let callMetadata = metadata; - if (!afterSnapshot) { - // Note: we should not modify metadata here to avoid side-effects in any other place. - callMetadata = { - ...metadata, - error: { error: { name: 'Error', message: 'Action was interrupted' } }, - }; - } - await this.onAfterCall(sdkObject, callMetadata); - } - if (state.options.snapshots) await this._snapshotter?.stop(); @@ -309,7 +295,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps return result; } - async _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) { + async _captureSnapshot(snapshotName: string, sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise { if (!this._snapshotter) return; if (!sdkObject.attribution.page) @@ -318,47 +304,43 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps return; if (!shouldCaptureSnapshot(metadata)) return; - const snapshotName = `${name}@${metadata.id}`; - metadata.snapshots.push({ title: name, snapshotName }); // We have |element| for input actions (page.click and handle.click) // and |sdkObject| element for accessors like handle.textContent. if (!element && sdkObject instanceof ElementHandle) element = sdkObject; - await this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName, element).catch(() => {}); + await this._snapshotter.captureSnapshot(sdkObject.attribution.page, metadata.id, snapshotName, element).catch(() => {}); } - async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { + onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { + // IMPORTANT: no awaits before this._appendTraceEvent in this method. + const event = createBeforeActionTraceEvent(metadata); + if (!event) + return Promise.resolve(); sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling(); - // Set afterSnapshot name for all the actions that operate selectors. - // Elements resolved from selectors will be marked on the snapshot. - metadata.afterSnapshot = `after@${metadata.id}`; - const beforeSnapshot = this._captureSnapshot('before', sdkObject, metadata); - this._pendingCalls.set(metadata.id, { sdkObject, metadata, beforeSnapshot }); - await beforeSnapshot; + event.beforeSnapshot = `before@${metadata.id}`; + this._appendTraceEvent(event); + return this._captureSnapshot(event.beforeSnapshot, sdkObject, metadata); } - async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle) { + onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle) { + // IMPORTANT: no awaits before this._appendTraceEvent in this method. + const event = createInputActionTraceEvent(metadata); + if (!event) + return Promise.resolve(); sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling(); - const actionSnapshot = this._captureSnapshot('action', sdkObject, metadata, element); - this._pendingCalls.get(metadata.id)!.actionSnapshot = actionSnapshot; - await actionSnapshot; + event.inputSnapshot = `input@${metadata.id}`; + this._appendTraceEvent(event); + return this._captureSnapshot(event.inputSnapshot, sdkObject, metadata, element); } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { + const event = createAfterActionTraceEvent(metadata); + if (!event) + return Promise.resolve(); sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling(); - const pendingCall = this._pendingCalls.get(metadata.id); - if (!pendingCall || pendingCall.afterSnapshot) - return; - if (!sdkObject.attribution.context) { - this._pendingCalls.delete(metadata.id); - return; - } - pendingCall.afterSnapshot = this._captureSnapshot('after', sdkObject, metadata); - await pendingCall.afterSnapshot; - const event = createActionTraceEvent(metadata); - if (event) - this._appendTraceEvent(event); - this._pendingCalls.delete(metadata.id); + event.afterSnapshot = `after@${metadata.id}`; + this._appendTraceEvent(event); + return this._captureSnapshot(event.afterSnapshot, sdkObject, metadata); } onEvent(sdkObject: SdkObject, event: trace.EventTraceEvent) { @@ -492,24 +474,41 @@ export function shouldCaptureSnapshot(metadata: CallMetadata): boolean { return commandsWithTracingSnapshots.has(metadata.type + '.' + metadata.method); } -function createActionTraceEvent(metadata: CallMetadata): trace.ActionTraceEvent | null { +function createBeforeActionTraceEvent(metadata: CallMetadata): trace.BeforeActionTraceEvent | null { if (metadata.internal || metadata.method.startsWith('tracing')) return null; return { - type: 'action', + type: 'before', callId: metadata.id, startTime: metadata.startTime, - endTime: metadata.endTime, apiName: metadata.apiName || metadata.type + '.' + metadata.method, class: metadata.type, method: metadata.method, params: metadata.params, wallTime: metadata.wallTime || Date.now(), - log: metadata.log, - snapshots: metadata.snapshots, - error: metadata.error?.error, - result: metadata.result, - point: metadata.point, pageId: metadata.pageId, }; } + +function createInputActionTraceEvent(metadata: CallMetadata): trace.InputActionTraceEvent | null { + if (metadata.internal || metadata.method.startsWith('tracing')) + return null; + return { + type: 'input', + callId: metadata.id, + point: metadata.point, + }; +} + +function createAfterActionTraceEvent(metadata: CallMetadata): trace.AfterActionTraceEvent | null { + if (metadata.internal || metadata.method.startsWith('tracing')) + return null; + return { + type: 'after', + callId: metadata.id, + endTime: metadata.endTime, + log: metadata.log, + error: metadata.error?.error, + result: metadata.result, + }; +} diff --git a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts index 07d20e48e8..adad5a8b0c 100644 --- a/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts +++ b/packages/playwright-core/src/server/trace/test/inMemorySnapshotter.ts @@ -56,11 +56,11 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot this._harTracer.stop(); } - async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise { + async captureSnapshot(page: Page, callId: string, snapshotName: string, element?: ElementHandle): Promise { if (this._frameSnapshots.has(snapshotName)) throw new Error('Duplicate snapshot name: ' + snapshotName); - this._snapshotter.captureSnapshot(page, snapshotName, element).catch(() => {}); + this._snapshotter.captureSnapshot(page, callId, snapshotName, element).catch(() => {}); return new Promise(fulfill => { const disposable = this.onSnapshotEvent((renderer: SnapshotRenderer) => { if (renderer.snapshotName === snapshotName) { diff --git a/packages/playwright-core/src/utils/traceUtils.ts b/packages/playwright-core/src/utils/traceUtils.ts index 2f3d3a2f21..b9a85b53db 100644 --- a/packages/playwright-core/src/utils/traceUtils.ts +++ b/packages/playwright-core/src/utils/traceUtils.ts @@ -16,11 +16,11 @@ import fs from 'fs'; import type EventEmitter from 'events'; -import type { ClientSideCallMetadata, StackFrame } from '@protocol/channels'; +import type { ClientSideCallMetadata, SerializedError, StackFrame } from '@protocol/channels'; import type { SerializedClientSideCallMetadata, SerializedStack, SerializedStackFrame } from './isomorphic/traceUtils'; import { yazl, yauzl } from '../zipBundle'; import { ManualPromise } from './manualPromise'; -import type { ActionTraceEvent } from '@trace/trace'; +import type { AfterActionTraceEvent, BeforeActionTraceEvent, TraceEvent } from '@trace/trace'; import { calculateSha1 } from './crypto'; import { monotonicTime } from './time'; @@ -96,7 +96,7 @@ export async function mergeTraceFiles(fileName: string, temporaryTraceFiles: str await mergePromise; } -export async function saveTraceFile(fileName: string, traceEvents: ActionTraceEvent[], saveSources: boolean) { +export async function saveTraceFile(fileName: string, traceEvents: TraceEvent[], saveSources: boolean) { const lines: string[] = traceEvents.map(e => JSON.stringify(e)); const zipFile = new yazl.ZipFile(); zipFile.addBuffer(Buffer.from(lines.join('\n')), 'trace.trace'); @@ -104,8 +104,10 @@ export async function saveTraceFile(fileName: string, traceEvents: ActionTraceEv if (saveSources) { const sourceFiles = new Set(); for (const event of traceEvents) { - for (const frame of event.stack || []) - sourceFiles.add(frame.file); + if (event.type === 'before') { + for (const frame of event.stack || []) + sourceFiles.add(frame.file); + } } for (const sourceFile of sourceFiles) { await fs.promises.readFile(sourceFile, 'utf8').then(source => { @@ -120,23 +122,30 @@ export async function saveTraceFile(fileName: string, traceEvents: ActionTraceEv }); } -export function createTraceEventForExpect(apiName: string, expected: any, stack: StackFrame[], wallTime: number): ActionTraceEvent { +export function createBeforeActionTraceEventForExpect(callId: string, apiName: string, expected: any, stack: StackFrame[]): BeforeActionTraceEvent { return { - type: 'action', - callId: 'expect@' + wallTime, - wallTime, + type: 'before', + callId, + wallTime: Date.now(), startTime: monotonicTime(), - endTime: 0, class: 'Test', method: 'step', apiName, params: { expected: generatePreview(expected) }, - snapshots: [], - log: [], stack, }; } +export function createAfterActionTraceEventForExpect(callId: string, error?: SerializedError['error']): AfterActionTraceEvent { + return { + type: 'after', + callId, + endTime: monotonicTime(), + log: [], + error, + }; +} + function generatePreview(value: any, visited = new Set()): string { if (visited.has(value)) return ''; diff --git a/packages/playwright-test/src/matchers/expect.ts b/packages/playwright-test/src/matchers/expect.ts index 1feb916404..1007d43ab7 100644 --- a/packages/playwright-test/src/matchers/expect.ts +++ b/packages/playwright-test/src/matchers/expect.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { captureRawStack, createTraceEventForExpect, monotonicTime, pollAgainstTimeout } from 'playwright-core/lib/utils'; +import { + captureRawStack, + createAfterActionTraceEventForExpect, + createBeforeActionTraceEventForExpect, + pollAgainstTimeout } from 'playwright-core/lib/utils'; import type { ExpectZone } from 'playwright-core/lib/utils'; import { toBeChecked, @@ -72,6 +76,8 @@ export type SyncExpectationResult = { // The replacement is compatible with pretty-format package. const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&'); +let lastCallId = 0; + export const printReceivedStringContainExpectedSubstring = ( received: string, start: number, @@ -215,9 +221,9 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { testInfo.currentStep = step; const generateTraceEvent = matcherName !== 'poll' && matcherName !== 'toPass'; - const traceEvent = generateTraceEvent ? createTraceEventForExpect(defaultTitle, args[0], stackFrames, wallTime) : undefined; - if (traceEvent) - testInfo._traceEvents.push(traceEvent); + const callId = ++lastCallId; + if (generateTraceEvent) + testInfo._traceEvents.push(createBeforeActionTraceEventForExpect(`expect@${callId}`, defaultTitle, args[0], stackFrames)); const reportStepError = (jestError: Error) => { const message = jestError.message; @@ -243,11 +249,11 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } const serializerError = serializeError(jestError); - if (traceEvent) { - traceEvent.error = { name: jestError.name, message: jestError.message, stack: jestError.stack }; - traceEvent.endTime = monotonicTime(); - step.complete({ error: serializerError }); + if (generateTraceEvent) { + const error = { name: jestError.name, message: jestError.message, stack: jestError.stack }; + testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, error)); } + step.complete({ error: serializerError }); if (this._info.isSoft) testInfo._failWithError(serializerError, false /* isHardError */); else @@ -255,8 +261,8 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { }; const finalizer = () => { - if (traceEvent) - traceEvent.endTime = monotonicTime(); + if (generateTraceEvent) + testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`)); step.complete({}); }; diff --git a/packages/protocol/src/callMetadata.ts b/packages/protocol/src/callMetadata.ts index ec83611c5f..1877310c0c 100644 --- a/packages/protocol/src/callMetadata.ts +++ b/packages/protocol/src/callMetadata.ts @@ -36,8 +36,6 @@ export type CallMetadata = { wallTime?: number; location?: { file: string, line?: number, column?: number }; log: string[]; - afterSnapshot?: string; - snapshots: { title: string, snapshotName: string }[]; error?: SerializedError; result?: any; point?: Point; diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index 05995a4a21..99f7e8cb0f 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -22,12 +22,14 @@ export class SnapshotRenderer { readonly snapshotName: string | undefined; _resources: ResourceSnapshot[]; private _snapshot: FrameSnapshot; + private _callId: string; constructor(resources: ResourceSnapshot[], snapshots: FrameSnapshot[], index: number) { this._resources = resources; this._snapshots = snapshots; this._index = index; this._snapshot = snapshots[index]; + this._callId = snapshots[index].callId; this.snapshotName = snapshots[index].snapshotName; } @@ -102,7 +104,7 @@ export class SnapshotRenderer { const prefix = snapshot.doctype ? `` : ''; html = prefix + [ '', - ``, + ``, `` ].join('') + html; diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index e92f8813d9..aea024595e 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -37,7 +37,8 @@ export class TraceModel { } async load(traceURL: string, progress: (done: number, total: number) => void) { - this._backend = traceURL.endsWith('json') ? new FetchTraceModelBackend(traceURL) : new ZipTraceModelBackend(traceURL, progress); + const isLive = traceURL.endsWith('json'); + this._backend = isLive ? new FetchTraceModelBackend(traceURL) : new ZipTraceModelBackend(traceURL, progress); const ordinals: string[] = []; let hasSource = false; @@ -55,16 +56,25 @@ export class TraceModel { for (const ordinal of ordinals) { const contextEntry = createEmptyContext(); + const actionMap = new Map(); contextEntry.traceUrl = traceURL; contextEntry.hasSource = hasSource; const trace = await this._backend.readText(ordinal + 'trace.trace') || ''; for (const line of trace.split('\n')) - this.appendEvent(contextEntry, line); + this.appendEvent(contextEntry, actionMap, line); const network = await this._backend.readText(ordinal + 'trace.network') || ''; for (const line of network.split('\n')) - this.appendEvent(contextEntry, line); + this.appendEvent(contextEntry, actionMap, line); + + contextEntry.actions = [...actionMap.values()].sort((a1, a2) => a1.startTime - a2.startTime); + if (!isLive) { + for (const action of contextEntry.actions) { + if (!action.endTime && !action.error) + action.error = { name: 'Error', message: 'Timed out' }; + } + } const stacks = await this._backend.readText(ordinal + 'trace.stacks'); if (stacks) { @@ -73,7 +83,6 @@ export class TraceModel { action.stack = action.stack || callMetadata.get(action.callId); } - contextEntry.actions.sort((a1, a2) => a1.startTime - a2.startTime); this.contextEntries.push(contextEntry); } } @@ -102,7 +111,7 @@ export class TraceModel { return pageEntry; } - appendEvent(contextEntry: ContextEntry, line: string) { + appendEvent(contextEntry: ContextEntry, actionMap: Map, line: string) { if (!line) return; const event = this._modernize(JSON.parse(line)); @@ -124,8 +133,27 @@ export class TraceModel { this._pageEntry(contextEntry, event.pageId).screencastFrames.push(event); break; } + case 'before': { + actionMap.set(event.callId, { ...event, type: 'action', endTime: 0, log: [] }); + break; + } + case 'input': { + const existing = actionMap.get(event.callId); + existing!.inputSnapshot = event.inputSnapshot; + existing!.point = event.point; + break; + } + case 'after': { + const existing = actionMap.get(event.callId); + existing!.afterSnapshot = event.afterSnapshot; + existing!.endTime = event.endTime; + existing!.log = event.log; + existing!.result = event.result; + existing!.error = event.error; + break; + } case 'action': { - contextEntry!.actions.push(event); + actionMap.set(event.callId, event); break; } case 'event': { @@ -144,10 +172,10 @@ export class TraceModel { this._snapshotStorage!.addFrameSnapshot(event.snapshot); break; } - if (event.type === 'action') { + if (event.type === 'action' || event.type === 'before') contextEntry.startTime = Math.min(contextEntry.startTime, event.startTime); + if (event.type === 'action' || event.type === 'after') contextEntry.endTime = Math.max(contextEntry.endTime, event.endTime); - } if (event.type === 'event') { contextEntry.startTime = Math.min(contextEntry.startTime, event.time); contextEntry.endTime = Math.max(contextEntry.endTime, event.time); @@ -251,7 +279,9 @@ export class TraceModel { params: metadata.params, wallTime: metadata.wallTime || Date.now(), log: metadata.log, - snapshots: metadata.snapshots, + beforeSnapshot: metadata.snapshots.find(s => s.snapshotName === 'before')?.snapshotName, + inputSnapshot: metadata.snapshots.find(s => s.snapshotName === 'input')?.snapshotName, + afterSnapshot: metadata.snapshots.find(s => s.snapshotName === 'after')?.snapshotName, error: metadata.error?.error, result: metadata.result, point: metadata.point, diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index ab58c30306..d934a2e192 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -42,11 +42,13 @@ export const SnapshotTab: React.FunctionComponent<{ const [pickerVisible, setPickerVisible] = React.useState(false); const { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => { - const snapshotMap = new Map(); - for (const snapshot of action?.snapshots || []) - snapshotMap.set(snapshot.title, snapshot); - const actionSnapshot = snapshotMap.get('action') || snapshotMap.get('after'); - const snapshots = [actionSnapshot ? { ...actionSnapshot, title: 'action' } : undefined, snapshotMap.get('before'), snapshotMap.get('after')].filter(Boolean) as { title: string, snapshotName: string }[]; + const actionSnapshot = action?.inputSnapshot || action?.afterSnapshot; + const snapshots = [ + actionSnapshot ? { title: 'action', snapshotName: actionSnapshot } : undefined, + action?.beforeSnapshot ? { title: 'before', snapshotName: action?.beforeSnapshot } : undefined, + action?.afterSnapshot ? { title: 'after', snapshotName: action.afterSnapshot } : undefined, + ].filter(Boolean) as { title: string, snapshotName: string }[]; + let snapshotUrl = 'data:text/html,'; let popoutUrl: string | undefined; let snapshotInfoUrl: string | undefined; @@ -60,7 +62,7 @@ export const SnapshotTab: React.FunctionComponent<{ params.set('name', snapshot.snapshotName); snapshotUrl = new URL(`snapshot/${action.pageId}?${params.toString()}`, window.location.href).toString(); snapshotInfoUrl = new URL(`snapshotInfo/${action.pageId}?${params.toString()}`, window.location.href).toString(); - if (snapshot.snapshotName.includes('action')) { + if (snapshot.title === 'action') { pointX = action.point?.x; pointY = action.point?.y; } diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx index b3ae97e5e3..e7f3ada542 100644 --- a/packages/trace-viewer/src/ui/watchMode.tsx +++ b/packages/trace-viewer/src/ui/watchMode.tsx @@ -33,6 +33,7 @@ import type { XtermDataSource } from '@web/components/xtermWrapper'; import { XtermWrapper } from '@web/components/xtermWrapper'; import { Expandable } from '@web/components/expandable'; import { toggleTheme } from '@web/theme'; +import { artifactsFolderName } from '@testIsomorphic/folders'; let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {}; let runWatchedTests = (fileName: string) => {}; @@ -392,30 +393,42 @@ const TraceView: React.FC<{ result: TestResult | undefined, }> = ({ outputDir, testCase, result }) => { const [model, setModel] = React.useState(); - const [currentStep, setCurrentStep] = React.useState(0); + const [counter, setCounter] = React.useState(0); const pollTimer = React.useRef(null); React.useEffect(() => { if (pollTimer.current) clearTimeout(pollTimer.current); - // Test finished. - const isFinished = result && result.duration >= 0; - if (isFinished) { - const attachment = result.attachments.find(a => a.name === 'trace'); - if (attachment && attachment.path) - loadSingleTraceFile(attachment.path).then(setModel); + if (!result) { + setModel(undefined); return; } - const traceLocation = `${outputDir}/.playwright-artifacts-${result?.workerIndex}/traces/${testCase?.id}.json`; + // Test finished. + const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace'); + if (attachment && attachment.path) { + loadSingleTraceFile(attachment.path).then(model => setModel(model)); + return; + } + + const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${testCase?.id}.json`; // Start polling running test. - pollTimer.current = setTimeout(() => { - loadSingleTraceFile(traceLocation).then(setModel).then(() => { - setCurrentStep(currentStep + 1); - }); + pollTimer.current = setTimeout(async () => { + try { + const model = await loadSingleTraceFile(traceLocation); + setModel(model); + } catch { + setModel(undefined); + } finally { + setCounter(counter + 1); + } }, 250); - }, [result, outputDir, testCase, currentStep, setCurrentStep]); + return () => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + }; + }, [result, outputDir, testCase, setModel, counter, setCounter]); return ; }; diff --git a/packages/trace-viewer/src/versions/traceV3.ts b/packages/trace-viewer/src/versions/traceV3.ts index 87bc658d9e..fea10264a7 100644 --- a/packages/trace-viewer/src/versions/traceV3.ts +++ b/packages/trace-viewer/src/versions/traceV3.ts @@ -97,6 +97,8 @@ export type ResourceOverride = { }; export type FrameSnapshot = { + // There was no callId in the original, we are intentionally regressing it. + callId: string; snapshotName?: string, pageId: string, frameId: string, diff --git a/packages/trace/src/snapshot.ts b/packages/trace/src/snapshot.ts index b7cd892994..affe5f497e 100644 --- a/packages/trace/src/snapshot.ts +++ b/packages/trace/src/snapshot.ts @@ -39,6 +39,7 @@ export type ResourceOverride = { export type FrameSnapshot = { snapshotName?: string, + callId: string, pageId: string, frameId: string, frameUrl: string, diff --git a/packages/trace/src/trace.ts b/packages/trace/src/trace.ts index acd7a7215c..f0cdac9f5e 100644 --- a/packages/trace/src/trace.ts +++ b/packages/trace/src/trace.ts @@ -51,23 +51,35 @@ export type ScreencastFrameTraceEvent = { timestamp: number, }; -export type ActionTraceEvent = { - type: 'action', +export type BeforeActionTraceEvent = { + type: 'before', callId: string; startTime: number; - endTime: number; apiName: string; class: string; method: string; params: any; wallTime: number; - log: string[]; - snapshots: { title: string, snapshotName: string }[]; + beforeSnapshot?: string; stack?: StackFrame[]; + pageId?: string; +}; + +export type InputActionTraceEvent = { + type: 'input', + callId: string; + inputSnapshot?: string; + point?: Point; +}; + +export type AfterActionTraceEvent = { + type: 'after', + callId: string; + endTime: number; + afterSnapshot?: string; + log: string[]; error?: SerializedError['error']; result?: any; - point?: Point; - pageId?: string; }; export type EventTraceEvent = { @@ -96,10 +108,19 @@ export type FrameSnapshotTraceEvent = { snapshot: FrameSnapshot, }; +export type ActionTraceEvent = { + type: 'action', +} & Omit + & Omit + & Omit; + export type TraceEvent = ContextCreatedTraceEvent | ScreencastFrameTraceEvent | ActionTraceEvent | + BeforeActionTraceEvent | + InputActionTraceEvent | + AfterActionTraceEvent | EventTraceEvent | ObjectTraceEvent | ResourceSnapshotTraceEvent | diff --git a/tests/config/utils.ts b/tests/config/utils.ts index 9f829506de..181a46d809 100644 --- a/tests/config/utils.ts +++ b/tests/config/utils.ts @@ -18,7 +18,7 @@ import type { Frame, Page } from 'playwright-core'; import { ZipFile } from '../../packages/playwright-core/lib/utils/zipFile'; import type { StackFrame } from '../../packages/protocol/src/channels'; import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils'; -import type { ActionTraceEvent } from '../../packages/trace/src/trace'; +import type { ActionTraceEvent, TraceEvent } from '../../packages/trace/src/trace'; export async function attachFrame(page: Page, frameId: string, url: string): Promise { const handle = await page.evaluateHandle(async ({ frameId, url }) => { @@ -101,11 +101,36 @@ export async function parseTrace(file: string): Promise<{ events: any[], resourc resources.set(entry, await zipFS.read(entry)); zipFS.close(); + const actionMap = new Map(); const events: any[] = []; for (const traceFile of [...resources.keys()].filter(name => name.endsWith('.trace'))) { for (const line of resources.get(traceFile)!.toString().split('\n')) { - if (line) - events.push(JSON.parse(line)); + if (line) { + const event = JSON.parse(line) as TraceEvent; + if (event.type === 'before') { + const action: ActionTraceEvent = { + ...event, + type: 'action', + endTime: 0, + log: [] + }; + events.push(action); + actionMap.set(event.callId, action); + } else if (event.type === 'input') { + const existing = actionMap.get(event.callId); + existing.inputSnapshot = event.inputSnapshot; + existing.point = event.point; + } else if (event.type === 'after') { + const existing = actionMap.get(event.callId); + existing.afterSnapshot = event.afterSnapshot; + existing.endTime = event.endTime; + existing.log = event.log; + existing.error = event.error; + existing.result = event.result; + } else { + events.push(event); + } + } } } diff --git a/tests/library/snapshotter.spec.ts b/tests/library/snapshotter.spec.ts index 7a0a0ca8f2..aa09fb0dfb 100644 --- a/tests/library/snapshotter.spec.ts +++ b/tests/library/snapshotter.spec.ts @@ -29,19 +29,19 @@ const it = contextTest.extend<{ snapshotter: InMemorySnapshotter }>({ it.describe('snapshots', () => { it('should collect snapshot', async ({ page, toImpl, snapshotter }) => { await page.setContent(''); - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot)).toBe(''); }); it('should preserve BASE and other content on reset', async ({ page, toImpl, snapshotter, server }) => { await page.goto(server.EMPTY_PAGE); - const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); + const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); const html1 = snapshot1.render().html; expect(html1).toContain(` { @@ -50,7 +50,7 @@ it.describe('snapshots', () => { route.fulfill({ body: 'button { color: red; }', }).catch(() => {}); }); await page.setContent(''); - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); const resource = snapshot.resourceByUrl(`http://localhost:${server.PORT}/style.css`); expect(resource).toBeTruthy(); }); @@ -59,36 +59,36 @@ it.describe('snapshots', () => { await page.setContent(''); const snapshots = []; snapshotter.onSnapshotEvent(snapshot => snapshots.push(snapshot)); - await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); - await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); + await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); + await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2'); expect(snapshots.length).toBe(2); }); it('should respect inline CSSOM change', async ({ page, toImpl, snapshotter }) => { await page.setContent(''); - const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); + const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot1)).toBe(''); await page.evaluate(() => { (document.styleSheets[0].cssRules[0] as any).style.color = 'blue'; }); - const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); + const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2'); expect(distillSnapshot(snapshot2)).toBe(''); }); it('should respect node removal', async ({ page, toImpl, snapshotter }) => { await page.setContent('
'); - const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); + const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot1)).toBe('
'); await page.evaluate(() => document.getElementById('button2').remove()); - const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); + const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2'); expect(distillSnapshot(snapshot2)).toBe('
'); }); it('should respect attr removal', async ({ page, toImpl, snapshotter }) => { await page.setContent('
'); - const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); + const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot1)).toBe('
'); await page.evaluate(() => document.getElementById('div').removeAttribute('attr2')); - const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); + const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot@call@2'); expect(distillSnapshot(snapshot2)).toBe('
'); }); @@ -96,21 +96,21 @@ it.describe('snapshots', () => { await page.goto(server.EMPTY_PAGE); await page.setContent('hi'); - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot)).toBe('hi'); }); it('should replace meta charset attr that specifies charset', async ({ page, server, toImpl, snapshotter }) => { await page.goto(server.EMPTY_PAGE); await page.setContent(''); - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot)).toBe(''); }); it('should replace meta content attr that specifies charset', async ({ page, server, toImpl, snapshotter }) => { await page.goto(server.EMPTY_PAGE); await page.setContent(''); - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot)).toBe(''); }); @@ -121,11 +121,11 @@ it.describe('snapshots', () => { }); await page.setContent(''); - const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); + const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot1)).toBe(''); await page.evaluate(() => { (document.styleSheets[0].cssRules[0] as any).style.color = 'blue'; }); - const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); + const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); const resource = snapshot2.resourceByUrl(`http://localhost:${server.PORT}/style.css`); expect((await snapshotter.resourceContentForTest(resource.response.content._sha1)).toString()).toBe('button { color: blue; }'); }); @@ -146,7 +146,7 @@ it.describe('snapshots', () => { await page.goto(server.EMPTY_PAGE); for (let counter = 0; ; ++counter) { - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot' + counter); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@' + counter, 'snapshot@call@' + counter); const text = distillSnapshot(snapshot).replace(/frame@[^"]+["]/, '"'); if (text === '\">') break; @@ -191,7 +191,7 @@ it.describe('snapshots', () => { // Marking iframe hierarchy is racy, do not expect snapshot, wait for it. for (let counter = 0; ; ++counter) { - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot' + counter); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@' + counter, 'snapshot@call@' + counter); const text = distillSnapshot(snapshot).replace(/frame@[^"]+["]/, '"'); if (text === '') break; @@ -203,31 +203,31 @@ it.describe('snapshots', () => { await page.setContent(''); { const handle = await page.$('text=Hello'); - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot', toImpl(handle)); - expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe(''); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1', toImpl(handle)); + expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe(''); } { const handle = await page.$('text=World'); - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2', toImpl(handle)); - expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe(''); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2', toImpl(handle)); + expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe(''); } }); it('should collect on attribute change', async ({ page, toImpl, snapshotter }) => { await page.setContent(''); { - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot)).toBe(''); } const handle = await page.$('text=Hello')!; await handle.evaluate(element => element.setAttribute('data', 'one')); { - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2'); expect(distillSnapshot(snapshot)).toBe(''); } await handle.evaluate(element => element.setAttribute('data', 'two')); { - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot@call@2'); expect(distillSnapshot(snapshot)).toBe(''); } }); @@ -251,11 +251,11 @@ it.describe('snapshots', () => { } }); - const renderer1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); + const renderer1 = await snapshotter.captureSnapshot(toImpl(page), 'call1', 'snapshot@call@1'); // Expect some adopted style sheets. expect(distillSnapshot(renderer1)).toContain('__playwright_style_sheet_'); - const renderer2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); + const renderer2 = await snapshotter.captureSnapshot(toImpl(page), 'call2', 'snapshot@call@2'); const snapshot2 = renderer2.snapshot(); // Second snapshot should be just a copy of the first one. expect(snapshot2.html).toEqual([[1, 13]]); @@ -263,7 +263,7 @@ it.describe('snapshots', () => { it('should not navigate on anchor clicks', async ({ page, toImpl, snapshotter }) => { await page.setContent('example.com'); - const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1'); expect(distillSnapshot(snapshot)).toBe('example.com'); }); }); diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index 26fc70ff01..1e73aac3ec 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -113,7 +113,8 @@ test('should not include buffers in the trace', async ({ context, page, server, await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); const { events } = await parseTrace(testInfo.outputPath('trace.zip')); const screenshotEvent = events.find(e => e.type === 'action' && e.apiName === 'page.screenshot'); - expect(screenshotEvent.snapshots.length).toBe(2); + expect(screenshotEvent.beforeSnapshot).toBeTruthy(); + expect(screenshotEvent.afterSnapshot).toBeTruthy(); expect(screenshotEvent.result).toEqual({}); }); @@ -405,7 +406,6 @@ test('should include interrupted actions', async ({ context, page, server }, tes const { events } = await parseTrace(testInfo.outputPath('trace.zip')); const clickEvent = events.find(e => e.apiName === 'page.click'); expect(clickEvent).toBeTruthy(); - expect(clickEvent.error.message).toBe('Action was interrupted'); }); test('should throw when starting with different options', async ({ context }) => { @@ -448,8 +448,6 @@ test('should work with multiple chunks', async ({ context, page, server }, testI 'page.click', 'page.click', ]); - expect(trace1.events.find(e => e.apiName === 'page.click' && !!e.error)).toBeTruthy(); - expect(trace1.events.find(e => e.apiName === 'page.click' && e.error?.message === 'Action was interrupted')).toBeTruthy(); expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBeTruthy(); expect(trace1.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).toBeTruthy(); diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index 80e477254d..eca8731d43 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -115,15 +115,15 @@ it('should support has:locator', async ({ page, trace }) => { await expect(page.locator(`div`, { has: page.locator(`text=world`) })).toHaveCount(1); - expect(await page.locator(`div`, { + expect(removeHighlight(await page.locator(`div`, { has: page.locator(`text=world`) - }).evaluate(e => e.outerHTML)).toBe(`
world
`); + }).evaluate(e => e.outerHTML))).toBe(`
world
`); await expect(page.locator(`div`, { has: page.locator(`text="hello"`) })).toHaveCount(1); - expect(await page.locator(`div`, { + expect(removeHighlight(await page.locator(`div`, { has: page.locator(`text="hello"`) - }).evaluate(e => e.outerHTML)).toBe(`
hello
`); + }).evaluate(e => e.outerHTML))).toBe(`
hello
`); await expect(page.locator(`div`, { has: page.locator(`xpath=./span`) })).toHaveCount(2); @@ -133,9 +133,9 @@ it('should support has:locator', async ({ page, trace }) => { await expect(page.locator(`div`, { has: page.locator(`span`, { hasText: 'wor' }) })).toHaveCount(1); - expect(await page.locator(`div`, { + expect(removeHighlight(await page.locator(`div`, { has: page.locator(`span`, { hasText: 'wor' }) - }).evaluate(e => e.outerHTML)).toBe(`
world
`); + }).evaluate(e => e.outerHTML))).toBe(`
world
`); await expect(page.locator(`div`, { has: page.locator(`span`), hasText: 'wor', @@ -180,3 +180,7 @@ it('alias methods coverage', async ({ page }) => { await expect(page.locator('div').getByRole('button')).toHaveCount(1); await expect(page.mainFrame().locator('button')).toHaveCount(1); }); + +function removeHighlight(markup: string) { + return markup.replace(/\s__playwright_target__="[^"]+"/, ''); +} \ No newline at end of file diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index fc16da6a0f..0b1fffb1c0 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -123,6 +123,7 @@ const testFiles = { }; test.slow(true, 'Multiple browser launches in each test'); +test.describe.configure({ mode: 'parallel' }); test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({