diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index 08adee0f6b99a..234f4262e46c2 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -278,7 +278,7 @@ export class TestTypeImpl { const testInfo = currentTestInfo(); if (!testInfo) throw new Error(`test.step() can only be called from a test`); - await testInfo._onUserStepBegin?.(title); + await testInfo._callbacks.onUserStepBegin?.(title); const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box }); return await currentZone().with('stepZone', step).run(async () => { try { @@ -302,7 +302,7 @@ export class TestTypeImpl { step.complete({ error }); throw error; } finally { - await testInfo._onUserStepEnd?.(); + await testInfo._callbacks.onUserStepEnd?.(); } }); } diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 4933dbc88fd78..8c07bd00e9129 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -31,8 +31,8 @@ import { config, testType } from './common'; import * as globals from './globals'; import { createCustomMessageHandler, runDaemonForContext } from './mcp/test/browserBackend'; -import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; -import type { TestInfoImpl, TestStepInternal } from './worker/testInfo'; +import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode, _TestInfoEx } from '../types/test'; +import type { TestStepInternal } from './worker/testInfo'; import type { ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation'; import type { Playwright as PlaywrightImpl } from '../../playwright-core/src/client/playwright'; import type { Browser as BrowserImpl } from '../../playwright-core/src/client/browser'; @@ -83,14 +83,15 @@ const utilityFixtures: Fixtures = { trace: ['off', { scope: 'worker', option: true, box: true }], testIdAttribute: ['data-testid', { option: true, box: true }], _combinedContextOptions: [{}, { box: true }], - _setupArtifacts: [async ({ playwright, screenshot, _combinedContextOptions }, use, testInfo) => { + _setupArtifacts: [async ({ playwright, screenshot, _combinedContextOptions }, use, _testInfo) => { + const testInfo = _testInfo as _TestInfoEx; // This fixture has a separate zero-timeout slot to ensure that artifact collection // happens even after some fixtures or hooks time out. // Now that default test timeout is known, we can replace zero with an actual value. testInfo.setTimeout(testInfo.project.timeout); - const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot); - await artifactsRecorder.willStartTest(testInfo as TestInfoImpl); + const artifactsRecorder = new ArtifactsRecorder(playwright, testInfo._artifactsDir(), screenshot); + await artifactsRecorder.willStartTest(testInfo); const tracingGroupSteps: TestStepInternal[] = []; const pausedContexts = new Set(); @@ -152,18 +153,18 @@ const utilityFixtures: Fixtures = { const paused = !!context.debugger.pausedDetails(); if (pausedContexts.has(context) && !paused) { pausedContexts.delete(context); - (testInfo as TestInfoImpl)._setIgnoreTimeouts(false); + testInfo._setIgnoreTimeouts(false); } else if (!pausedContexts.has(context) && paused) { pausedContexts.add(context); - (testInfo as TestInfoImpl)._setIgnoreTimeouts(true); + testInfo._setIgnoreTimeouts(true); } }); await artifactsRecorder.didCreateBrowserContext(context); - const currentTestInfo = globals.currentTestInfo() as TestInfoImpl | undefined; + const currentTestInfo = globals.currentTestInfo(); if (currentTestInfo) { attachConnectedHeaderIfNeeded(currentTestInfo, context.browser()); - currentTestInfo._onCustomMessageCallback = createCustomMessageHandler(currentTestInfo, context); + currentTestInfo._callbacks.onCustomMessage = createCustomMessageHandler(currentTestInfo, context); await runDaemonForContext(currentTestInfo, context); } }, @@ -190,7 +191,7 @@ const utilityFixtures: Fixtures = { request: async ({ playwright }, use) => { const request = await playwright.request.newContext(); await use(request); - const hook = (test.info() as TestInfoImpl)._currentHookType(); + const hook = (test.info() as _TestInfoEx)._currentHookType(); if (hook === 'beforeAll') { await request.dispose({ reason: [ `Fixture { request } from beforeAll cannot be reused in a test.`, @@ -221,8 +222,8 @@ const playwrightFixtures: Fixtures { - const testInfo = _testInfo as TestInfoImpl; + const testInfo = _testInfo as _TestInfoEx; if (testIdAttribute) playwrightLibrary.selectors.setTestIdAttribute(testIdAttribute); testInfo.snapshotSuffix = process.platform; - testInfo._onCustomMessageCallback = () => Promise.reject(new Error('Only tests that use default Playwright context or page fixture support test_debug')); + testInfo._callbacks.onCustomMessage = () => Promise.reject(new Error('Only tests that use default Playwright context or page fixture support test_debug')); if (debugMode() === 'inspector') - (testInfo as TestInfoImpl)._setIgnoreTimeouts(true); + testInfo._setIgnoreTimeouts(true); playwright._defaultContextTimeout = actionTimeout || 0; playwright._defaultContextNavigationTimeout = navigationTimeout || 0; @@ -397,14 +398,14 @@ const playwrightFixtures: Fixtures { - const testInfoImpl = testInfo as TestInfoImpl; + const testInfoEx = testInfo as _TestInfoEx; const videoMode = normalizeVideoMode(video); const captureVideo = shouldCaptureVideo(videoMode, testInfo) && !_reuseContext; const contexts = new Map Promise, pagesWithVideo: Page[] }>(); let counter = 0; await use(async options => { - const hook = testInfoImpl._currentHookType(); + const hook = testInfoEx._currentHookType(); if (hook === 'beforeAll' || hook === 'afterAll') { throw new Error([ `"context" and "page" fixtures are not supported in "${hook}" since they are created on a per-test basis.`, @@ -415,7 +416,7 @@ const playwrightFixtures: Fixtures { const browserImpl = browser as BrowserImpl; - const testInfo = testInfoPublic as TestInfoImpl; + const testInfo = testInfoPublic as _TestInfoEx; const show = typeof video === 'string' ? undefined : video.show; attachConnectedHeaderIfNeeded(testInfo, browserImpl); if (!_reuseContext) { @@ -611,6 +612,7 @@ function resolveClientCerticates(clientCertificates: ClientCertificates): Client } const kTracingStarted = Symbol('kTracingStarted'); +let traceOrdinal = 0; function connectOptionsFromEnv() { const wsEndpoint = process.env.PW_TEST_CONNECT_WS_ENDPOINT; @@ -624,125 +626,40 @@ function connectOptionsFromEnv() { }; } -type SnapshotRecorderMode = 'on' | 'off' | 'only-on-failure' | 'on-first-failure'; - -class SnapshotRecorder { - private _ordinal = 0; - private _temporary: string[] = []; - - constructor( - private _artifactsRecorder: ArtifactsRecorder, - private _mode: SnapshotRecorderMode, - private _name: string, - private _contentType: string, - private _extension: string, - private _doSnapshot: (page: PageImpl, path: string) => Promise) { - } - - fixOrdinal() { - // Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not - // overwrite previous screenshots. - this._ordinal = this.testInfo.attachments.filter(a => a.name === this._name).length; - } - - private shouldCaptureUponFinish() { - return this._mode === 'on' || - (this._mode === 'only-on-failure' && this.testInfo._isFailure()) || - (this._mode === 'on-first-failure' && this.testInfo._isFailure() && this.testInfo.retry === 0); - } - - async maybeCapture() { - if (!this.shouldCaptureUponFinish()) - return; - - await Promise.all(this._artifactsRecorder._playwright._allPages().map(page => this._snapshotPage(page, false))); - } - - async persistTemporary() { - if (this.shouldCaptureUponFinish()) { - await Promise.all(this._temporary.map(async file => { - try { - const path = this._createAttachmentPath(); - await fs.promises.rename(file, path); - this._attach(path); - } catch { - } - })); - } - } - - async captureTemporary(context: BrowserContextImpl) { - if (this._mode === 'on' || this._mode === 'only-on-failure' || (this._mode === 'on-first-failure' && this.testInfo.retry === 0)) - await Promise.all(context.pages().map(page => this._snapshotPage(page, true))); - } - - private _attach(screenshotPath: string) { - this.testInfo.attachments.push({ name: this._name, path: screenshotPath, contentType: this._contentType }); - } - - private _createAttachmentPath() { - const testFailed = this.testInfo._isFailure(); - const index = this._ordinal + 1; - ++this._ordinal; - const path = this.testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${index}${this._extension}`); - return path; - } - - private _createTemporaryArtifact(...name: string[]) { - const file = path.join(this._artifactsRecorder._artifactsDir, ...name); - return file; - } - - private async _snapshotPage(page: PageImpl, temporary: boolean) { - // Make sure we do not snapshot the same page twice for a single TestInfo, - // which is reused between beforeAll(s), test and afterAll(s). - if ((page as any)[this.testInfo._uniqueSymbol]) - return; - (page as any)[this.testInfo._uniqueSymbol] = true; - try { - const path = temporary ? this._createTemporaryArtifact(createGuid() + this._extension) : this._createAttachmentPath(); - await this._doSnapshot(page, path); - if (temporary) - this._temporary.push(path); - else - this._attach(path); - } catch { - // snapshot may fail, just ignore. - } - } - - private get testInfo(): TestInfoImpl { - return this._artifactsRecorder._testInfo; - } +function isFailure(testInfo: TestInfo) { + return testInfo.status !== 'skipped' && testInfo.status !== testInfo.expectedStatus; } +// Stamped on the page, holding the TestInfo the page was last snapshotted for. +const kSnapshottedForTestInfo = Symbol('snapshottedForTestInfo'); + class ArtifactsRecorder { - _testInfo!: TestInfoImpl; + _testInfo!: _TestInfoEx; _playwright: PlaywrightImpl; _artifactsDir: string; private _startedCollectingArtifacts: symbol; - private _screenshotRecorder: SnapshotRecorder; + private _screenshotMode: ScreenshotMode; + private _screenshotOptions: Exclude; + private _screenshotOrdinal = 0; + private _temporaryScreenshots: string[] = []; private _pageSnapshot: string | undefined; constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption) { this._playwright = playwright; this._artifactsDir = artifactsDir; - const screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot; + this._screenshotMode = normalizeScreenshotMode(screenshot); + this._screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot; this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts'); - - this._screenshotRecorder = new SnapshotRecorder(this, normalizeScreenshotMode(screenshot), 'screenshot', 'image/png', '.png', async (page, path) => { - await page._wrapApiCall(async () => { - await page.screenshot({ ...screenshotOptions, timeout: 5000, path, caret: 'initial' }); - }, { internal: true }); - }); } - async willStartTest(testInfo: TestInfoImpl) { + async willStartTest(testInfo: _TestInfoEx) { this._testInfo = testInfo; - testInfo._onDidFinishTestFunctionCallbacks.add(() => this.didFinishTestFunction()); + testInfo._callbacks.onDidFinishTestFunction = () => this.didFinishTestFunction(); - this._screenshotRecorder.fixOrdinal(); + // Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not + // overwrite previous screenshots. + this._screenshotOrdinal = testInfo.attachments.filter(a => a.name === 'screenshot').length; // Process existing contexts. await Promise.all(this._playwright._allContexts().map(context => this.didCreateBrowserContext(context))); @@ -757,7 +674,8 @@ class ArtifactsRecorder { async willCloseBrowserContext(context: BrowserContextImpl) { await this._stopTracing(context, context.tracing); - await this._screenshotRecorder.captureTemporary(context); + if (this._screenshotMode === 'on' || this._screenshotMode === 'only-on-failure' || (this._screenshotMode === 'on-first-failure' && this._testInfo.retry === 0)) + await Promise.all(context.pages().map(page => this._screenshotPage(page, true))); await this._takePageSnapshot(context); } @@ -793,7 +711,8 @@ class ArtifactsRecorder { } async didFinishTestFunction() { - await this._screenshotRecorder.maybeCapture(); + if (this._shouldCaptureScreenshotUponFinish()) + await Promise.all(this._playwright._allPages().map(page => this._screenshotPage(page, false))); } async didFinishTest() { @@ -810,7 +729,7 @@ class ArtifactsRecorder { await this._stopTracing(context, context.tracing); }))); - await this._screenshotRecorder.persistTemporary(); + await this._persistTemporaryScreenshots(); const context = leftoverContexts[0]; if (context) @@ -827,21 +746,68 @@ class ArtifactsRecorder { if (errorContextContent) { const filePath = this._testInfo.outputPath('error-context.md'); await fs.promises.writeFile(filePath, errorContextContent, 'utf8'); - this._testInfo._attach({ + this._testInfo.attachments.push({ name: 'error-context', contentType: 'text/markdown', path: filePath, - }, undefined); + }); } } } + private _shouldCaptureScreenshotUponFinish() { + return this._screenshotMode === 'on' || + (this._screenshotMode === 'only-on-failure' && isFailure(this._testInfo)) || + (this._screenshotMode === 'on-first-failure' && isFailure(this._testInfo) && this._testInfo.retry === 0); + } + + private async _screenshotPage(page: PageImpl, temporary: boolean) { + // Make sure we do not screenshot the same page twice for a single TestInfo, + // which is reused between beforeAll(s), test and afterAll(s), + // each having their own ArtifactsRecorder. + if ((page as any)[kSnapshottedForTestInfo] === this._testInfo) + return; + (page as any)[kSnapshottedForTestInfo] = this._testInfo; + try { + const file = temporary ? path.join(this._artifactsDir, createGuid() + '.png') : this._createScreenshotAttachmentPath(); + await page._wrapApiCall(async () => { + await page.screenshot({ ...this._screenshotOptions, timeout: 5000, path: file, caret: 'initial' }); + }, { internal: true }); + if (temporary) + this._temporaryScreenshots.push(file); + else + this._testInfo.attachments.push({ name: 'screenshot', path: file, contentType: 'image/png' }); + } catch { + // Screenshot may fail, just ignore. + } + } + + private async _persistTemporaryScreenshots() { + if (!this._shouldCaptureScreenshotUponFinish()) + return; + await Promise.all(this._temporaryScreenshots.map(async file => { + try { + const screenshotPath = this._createScreenshotAttachmentPath(); + await fs.promises.rename(file, screenshotPath); + this._testInfo.attachments.push({ name: 'screenshot', path: screenshotPath, contentType: 'image/png' }); + } catch { + } + })); + } + + private _createScreenshotAttachmentPath() { + const testFailed = isFailure(this._testInfo); + const index = this._screenshotOrdinal + 1; + ++this._screenshotOrdinal; + return this._testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${index}.png`); + } + private async _startTraceChunkOnContextCreation(channelOwner: ChannelOwner, tracing: Tracing) { await channelOwner._wrapApiCall(async () => { - const options = this._testInfo._tracing.traceOptions(); + const options = this._testInfo._traceOptions(); if (options) { - const title = this._testInfo._tracing.traceTitle(); - const name = this._testInfo._tracing.generateNextTraceRecordingName(); + const title = this._testInfo._traceTitle(); + const name = this._generateNextTraceRecordingName(); if (!(tracing as any)[kTracingStarted]) { await tracing.start({ ...options, title, name }); (tracing as any)[kTracingStarted] = true; @@ -857,18 +823,30 @@ class ArtifactsRecorder { }, { internal: true }); } + private _generateNextTraceRecordingName() { + const ordinalSuffix = traceOrdinal ? `-recording${traceOrdinal}` : ''; + ++traceOrdinal; + const retrySuffix = this._testInfo.retry ? `-retry${this._testInfo.retry}` : ''; + // Note that trace name must start with testId for live tracing to work. + return `${this._testInfo.testId}${retrySuffix}${ordinalSuffix}`; + } + private async _stopTracing(channelOwner: ChannelOwner, tracing: Tracing) { await channelOwner._wrapApiCall(async () => { if ((tracing as any)[this._startedCollectingArtifacts]) return; (tracing as any)[this._startedCollectingArtifacts] = true; - if (this._testInfo._tracing.traceOptions() && (tracing as any)[kTracingStarted]) - await tracing.stopChunk({ path: this._testInfo._tracing.maybeGenerateNextTraceRecordingPath() }); + if (this._testInfo._traceOptions() && (tracing as any)[kTracingStarted]) { + const file = this._testInfo._shouldKeepTrace() ? path.join(this._artifactsDir, createGuid() + '.zip') : undefined; + await tracing.stopChunk({ path: file }); + if (file) + this._testInfo._appendTraceFile(file); + } }, { internal: true }); } } -async function installScreencastTitleUpdater(testInfo: TestInfoImpl, context: BrowserContext, testAnnotate?: { level?: 'file' | 'title' | 'step', position?: string, fontSize?: number }) { +async function installScreencastTitleUpdater(testInfo: _TestInfoEx, context: BrowserContext, testAnnotate?: { level?: 'file' | 'title' | 'step', position?: string, fontSize?: number }) { if (!testAnnotate) return; @@ -889,11 +867,11 @@ async function installScreencastTitleUpdater(testInfo: TestInfoImpl, context: Br overlays.set(page, disposable); } }; - testInfo._onUserStepBegin = async title => { + testInfo._callbacks.onUserStepBegin = async title => { stepStack.push(title); await updateOverlay(); }; - testInfo._onUserStepEnd = async () => { + testInfo._callbacks.onUserStepEnd = async () => { stepStack.pop(); await updateOverlay(); }; @@ -927,10 +905,6 @@ function renderTitle(type: string, method: string, params: Record(playwrightFixtures); export { defineConfig, mergeTests } from './common'; diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index d7d107cd844f0..c77f6435e138f 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -32,7 +32,7 @@ import { testInfoError } from './util'; import { ipc, transform } from '../common'; import type { RunnableDescription } from './timeoutManager'; -import type { FullProject, TestInfo, TestInfoError, TestStatus, TestStepInfo, TestAnnotation } from '../../types/test'; +import type { FullProject, TestInfo, TestInfoError, TestStatus, TestStepInfo, TestAnnotation, _TestInfoEx } from '../../types/test'; import type { FullConfig, Location } from '../../types/testReporter'; import type { config as commonConfig, FullConfigInternal, test as testNs } from '../common'; import type { StackFrame } from '@utils/stackTrace'; @@ -82,15 +82,14 @@ export const emtpyTestInfoCallbacks: TestInfoCallbacks = { onTestPaused: () => Promise.reject(new Error('TestInfoImpl not initialized')), }; -export class TestInfoImpl implements TestInfo { - private _callbacks: TestInfoCallbacks; +export class TestInfoImpl implements _TestInfoEx { + private _ipcCallbacks: TestInfoCallbacks; private _snapshotNames: SnapshotNames = { lastAnonymousSnapshotIndex: 0, lastNamedSnapshotIndex: {} }; private _ariaSnapshotNames: SnapshotNames = { lastAnonymousSnapshotIndex: 0, lastNamedSnapshotIndex: {} }; readonly _timeoutManager: TimeoutManager; readonly _startTime: number; readonly _startWallTime: number; readonly _tracing: TestTracing; - readonly _uniqueSymbol; private _interruptedPromise = new ManualPromise(); _lastStepId = 0; @@ -99,10 +98,7 @@ export class TestInfoImpl implements TestInfo { readonly _configInternal: FullConfigInternal; private readonly _steps: TestStepInternal[] = []; private readonly _stepMap = new Map(); - _onDidFinishTestFunctionCallbacks = new Set<() => Promise>(); - _onCustomMessageCallback?: (data: any) => Promise; - _onUserStepBegin?: (title: string) => Promise; - _onUserStepEnd?: () => Promise; + readonly _callbacks: _TestInfoEx['_callbacks'] = {}; _hasNonRetriableError = false; _hasUnhandledError = false; _allowSkips = false; @@ -172,11 +168,10 @@ export class TestInfoImpl implements TestInfo { callbacks: TestInfoCallbacks ) { this.testId = test?.id ?? ''; - this._callbacks = callbacks; + this._ipcCallbacks = callbacks; this._startTime = monotonicTime(); this._startWallTime = Date.now(); this._requireFile = test?._requireFile ?? ''; - this._uniqueSymbol = Symbol('testInfoUniqueSymbol'); this._workerParams = workerParams; this.repeatEachIndex = workerParams.repeatEachIndex; @@ -357,7 +352,7 @@ export class TestInfoImpl implements TestInfo { suggestedRebaseline: result.suggestedRebaseline, annotations: step.info.annotations, }; - this._callbacks.onStepEnd(payload); + this._ipcCallbacks.onStepEnd(payload); } if (step.group !== 'internal') { const errorForTrace = step.error ? { name: '', message: step.error.message || '', stack: step.error.stack } : undefined; @@ -380,7 +375,7 @@ export class TestInfoImpl implements TestInfo { wallTime: Date.now(), location: step.location, }; - this._callbacks.onStepBegin(payload); + this._ipcCallbacks.onStepBegin(payload); } if (step.group !== 'internal') { this._tracing.appendBeforeActionForStep({ @@ -478,7 +473,7 @@ export class TestInfoImpl implements TestInfo { _currentHookType() { const type = this._timeoutManager.currentSlotType(); - return ['beforeAll', 'afterAll', 'beforeEach', 'afterEach'].includes(type) ? type : undefined; + return (['beforeAll', 'afterAll', 'beforeEach', 'afterEach'] as const).find(t => t === type); } _setIgnoreTimeouts(ignoreTimeouts: boolean) { @@ -490,12 +485,11 @@ export class TestInfoImpl implements TestInfo { const shouldPause = (this._workerParams.pauseAtEnd && !this._isFailure()) || (this._workerParams.pauseOnError && this._isFailure()); if (shouldPause) { await Promise.race([ - this._callbacks.onTestPaused({ testId: this.testId, errors: this._isFailure() ? this.errors.map(ipc.toTestInfoErrorPayload) : [], status: this.status }), + this._ipcCallbacks.onTestPaused({ testId: this.testId, errors: this._isFailure() ? this.errors.map(ipc.toTestInfoErrorPayload) : [], status: this.status }), this._interruptedPromise, ]); } - for (const cb of this._onDidFinishTestFunctionCallbacks) - await cb(); + await this._callbacks.onDidFinishTestFunction?.(); } // ------------ TestInfo methods ------------ @@ -527,7 +521,7 @@ export class TestInfoImpl implements TestInfo { this._tracing.appendAfterActionForStep(stepId, undefined, [attachment]); } - this._callbacks.onAttach({ + this._ipcCallbacks.onAttach({ testId: this.testId, name: attachment.name, contentType: attachment.contentType, @@ -664,9 +658,29 @@ export class TestInfoImpl implements TestInfo { this._timeoutManager.setTimeout(timeout); } - artifactsDir(): string { + _artifactsDir(): string { return this._workerParams.artifactsDir; } + + _tracesDir(): string { + return this._tracing.tracesDir(); + } + + _traceOptions() { + return this._tracing.traceOptions(); + } + + _traceTitle(): string { + return this._tracing.traceTitle(); + } + + _shouldKeepTrace(): boolean { + return this._tracing.shouldKeepTrace(); + } + + _appendTraceFile(file: string) { + this._tracing.appendTraceFile(file); + } } export class TestStepInfoImpl implements TestStepInfo { diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 962e945b7b6e2..7fe0db1c15ae4 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -35,10 +35,9 @@ import type EventEmitter from 'events'; export type Attachment = TestInfo['attachments'][0]; export const testTraceEntryName = 'test.trace'; const version: trace.VERSION = 9; -let traceOrdinal = 0; type TraceFixtureValue = PlaywrightWorkerOptions['trace'] | undefined; -type TraceOptions = { screencast?: boolean | { size?: { width: number, height: number }, quality?: number }, screenshots: boolean, snapshots: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, sources: boolean, attachments: boolean, live: boolean, mode: TraceMode }; +type TraceOptions = Exclude & { live: boolean }; export class TestTracing { private _testInfo: TestInfoImpl; @@ -134,14 +133,6 @@ export class TestTracing { return [path.relative(this._testInfo.project.testDir, this._testInfo.file) + ':' + this._testInfo.line, ...this._testInfo.titlePath.slice(1)].join(' › '); } - generateNextTraceRecordingName() { - const ordinalSuffix = traceOrdinal ? `-recording${traceOrdinal}` : ''; - ++traceOrdinal; - const retrySuffix = this._testInfo.retry ? `-retry${this._testInfo.retry}` : ''; - // Note that trace name must start with testId for live tracing to work. - return `${this._testInfo.testId}${retrySuffix}${ordinalSuffix}`; - } - private _generateNextTraceRecordingPath() { const file = path.join(this._artifactsDir, createGuid() + '.zip'); this._temporaryTraceFiles.push(file); @@ -152,13 +143,15 @@ export class TestTracing { return this._options; } - maybeGenerateNextTraceRecordingPath() { + shouldKeepTrace() { // Forget about traces that should be saved on failure, when no failure happened // during the test and beforeEach/afterEach hooks. // This avoids downloading traces over the wire when not really needed. - if (this._didFinishTestFunctionAndAfterEachHooks && this._shouldAbandonTrace()) - return; - return this._generateNextTraceRecordingPath(); + return !(this._didFinishTestFunctionAndAfterEachHooks && this._shouldAbandonTrace()); + } + + appendTraceFile(file: string) { + this._temporaryTraceFiles.push(file); } private _shouldAbandonTrace() { @@ -174,9 +167,6 @@ export class TestTracing { this._contextCreatedEvent.testTimeout = this._testInfo.timeout; this._contextCreatedEvent.annotations = this._testInfo.annotations.map(({ type, description }) => ({ type, description })); - if (!this._options) - return; - await this._liveTraceFile?.fs.sync(); if (this._shouldAbandonTrace()) { diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index efb16b9d71d7e..93332783e6e3d 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -278,7 +278,7 @@ export class WorkerMain extends ProcessRunner { try { if (this._currentTest?.testId !== payload.testId) throw new Error('Test has already stopped'); - const response = await this._currentTest._onCustomMessageCallback?.(payload.request); + const response = await this._currentTest._callbacks.onCustomMessage?.(payload.request); return { response }; } catch (error) { return { response: {}, error: ipc.toTestInfoErrorPayload(testInfoError(error)) }; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index f89fd4c330a8f..66bad733a3e41 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -8963,6 +8963,23 @@ export default test; export const _baseTest: TestType<{}, {}>; export const expect: Expect<{}>; +export interface _TestInfoEx extends TestInfo { + readonly _callbacks: { + onDidFinishTestFunction?: () => Promise; + onCustomMessage?: (data: any) => Promise; + onUserStepBegin?: (title: string) => Promise; + onUserStepEnd?: () => Promise; + }; + _setIgnoreTimeouts(ignoreTimeouts: boolean): void; + _currentHookType(): 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach' | undefined; + _artifactsDir(): string; + _tracesDir(): string; + _traceOptions(): (Exclude & { live: boolean }) | undefined; + _traceTitle(): string; + _shouldKeepTrace(): boolean; + _appendTraceFile(file: string): void; +} + /** * Defines Playwright config */ diff --git a/utils/generate_types/index.js b/utils/generate_types/index.js index 253ab30f1c9f1..0c10b55564243 100644 --- a/utils/generate_types/index.js +++ b/utils/generate_types/index.js @@ -588,6 +588,7 @@ class TypesGenerator { 'PlaywrightWorkerOptions.defaultBrowserType', 'PlaywrightWorkerOptions.reuseContext', 'Project', + '_TestInfoEx', ]), doNotExportClassNames: assertionClasses, }); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index ee28e42b068aa..6be1593228584 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -532,6 +532,23 @@ export default test; export const _baseTest: TestType<{}, {}>; export const expect: Expect<{}>; +export interface _TestInfoEx extends TestInfo { + readonly _callbacks: { + onDidFinishTestFunction?: () => Promise; + onCustomMessage?: (data: any) => Promise; + onUserStepBegin?: (title: string) => Promise; + onUserStepEnd?: () => Promise; + }; + _setIgnoreTimeouts(ignoreTimeouts: boolean): void; + _currentHookType(): 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach' | undefined; + _artifactsDir(): string; + _tracesDir(): string; + _traceOptions(): (Exclude & { live: boolean }) | undefined; + _traceTitle(): string; + _shouldKeepTrace(): boolean; + _appendTraceFile(file: string): void; +} + /** * Defines Playwright config */