Skip to content

Commit ba5b460

Browse files
authored
chore: move artifacts recording to TestLifecycleInstrumentation (#30935)
The spirit of this change is reverting #23153. Since that time, we have moved tracing and `artifactsDir` lifetime into the test runner, so the reason for revert is mitigated. Fixes #30287, fixes #30718, fixes #30959.
1 parent f93da40 commit ba5b460

File tree

8 files changed

+266
-80
lines changed

8 files changed

+266
-80
lines changed

packages/playwright-core/src/client/tracing.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,24 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
3434
}
3535

3636
async start(options: { name?: string, title?: string, snapshots?: boolean, screenshots?: boolean, sources?: boolean, _live?: boolean } = {}) {
37-
this._includeSources = !!options.sources;
38-
const traceName = await this._wrapApiCall(async () => {
37+
await this._wrapApiCall(async () => {
38+
this._includeSources = !!options.sources;
3939
await this._channel.tracingStart({
4040
name: options.name,
4141
snapshots: options.snapshots,
4242
screenshots: options.screenshots,
4343
live: options._live,
4444
});
4545
const response = await this._channel.tracingStartChunk({ name: options.name, title: options.title });
46-
return response.traceName;
46+
await this._startCollectingStacks(response.traceName);
4747
}, true);
48-
await this._startCollectingStacks(traceName);
4948
}
5049

5150
async startChunk(options: { name?: string, title?: string } = {}) {
52-
const { traceName } = await this._channel.tracingStartChunk(options);
53-
await this._startCollectingStacks(traceName);
51+
await this._wrapApiCall(async () => {
52+
const { traceName } = await this._channel.tracingStartChunk(options);
53+
await this._startCollectingStacks(traceName);
54+
}, true);
5455
}
5556

5657
private async _startCollectingStacks(traceName: string) {

packages/playwright/src/common/globals.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,19 @@ export function setIsWorkerProcess() {
4242
export function isWorkerProcess() {
4343
return _isWorkerProcess;
4444
}
45+
46+
export interface TestLifecycleInstrumentation {
47+
onTestBegin?(): Promise<void>;
48+
onTestFunctionEnd?(): Promise<void>;
49+
onTestEnd?(): Promise<void>;
50+
}
51+
52+
let _testLifecycleInstrumentation: TestLifecycleInstrumentation | undefined;
53+
54+
export function setTestLifecycleInstrumentation(instrumentation: TestLifecycleInstrumentation | undefined) {
55+
_testLifecycleInstrumentation = instrumentation;
56+
}
57+
58+
export function testLifecycleInstrumentation() {
59+
return _testLifecycleInstrumentation;
60+
}

packages/playwright/src/index.ts

Lines changed: 108 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@
1616

1717
import * as fs from 'fs';
1818
import * as path from 'path';
19-
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
20-
import * as playwrightLibrary from 'playwright-core';
19+
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video, PageScreenshotOptions } from 'playwright-core';
2120
import { createGuid, debugMode, addInternalStackPrefix, isString, asLocator, jsonStringifyForceASCII } from 'playwright-core/lib/utils';
2221
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
2322
import type { TestInfoImpl } from './worker/testInfo';
2423
import { rootTestType } from './common/testType';
2524
import type { ContextReuseMode } from './common/config';
2625
import type { ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
27-
import { currentTestInfo } from './common/globals';
26+
import { currentTestInfo, setTestLifecycleInstrumentation, type TestLifecycleInstrumentation } from './common/globals';
2827
export { expect } from './matchers/expect';
2928
export const _baseTest: TestType<{}, {}> = rootTestType.test;
3029

@@ -45,11 +44,12 @@ if ((process as any)['__pw_initiator__']) {
4544
type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
4645
_combinedContextOptions: BrowserContextOptions,
4746
_setupContextOptions: void;
48-
_setupArtifacts: void;
4947
_contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>;
5048
};
5149

5250
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
51+
// Same as "playwright", but exposed so that our internal tests can override it.
52+
_playwrightImpl: PlaywrightWorkerArgs['playwright'];
5353
_browserOptions: LaunchOptions;
5454
_optionContextReuseMode: ContextReuseMode,
5555
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
@@ -59,9 +59,14 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
5959
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
6060
defaultBrowserType: ['chromium', { scope: 'worker', option: true }],
6161
browserName: [({ defaultBrowserType }, use) => use(defaultBrowserType), { scope: 'worker', option: true }],
62-
playwright: [async ({}, use) => {
63-
await use(require('playwright-core'));
62+
_playwrightImpl: [({}, use) => use(require('playwright-core')), { scope: 'worker' }],
63+
64+
playwright: [async ({ _playwrightImpl, screenshot }, use) => {
65+
await connector.setPlaywright(_playwrightImpl, screenshot);
66+
await use(_playwrightImpl);
67+
await connector.setPlaywright(undefined, screenshot);
6468
}, { scope: 'worker', _hideStep: true } as any],
69+
6570
headless: [({ launchOptions }, use) => use(launchOptions.headless ?? true), { scope: 'worker', option: true }],
6671
channel: [({ launchOptions }, use) => use(launchOptions.channel), { scope: 'worker', option: true }],
6772
launchOptions: [{}, { scope: 'worker', option: true }],
@@ -222,7 +227,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
222227

223228
_setupContextOptions: [async ({ playwright, _combinedContextOptions, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => {
224229
if (testIdAttribute)
225-
playwrightLibrary.selectors.setTestIdAttribute(testIdAttribute);
230+
playwright.selectors.setTestIdAttribute(testIdAttribute);
226231
testInfo.snapshotSuffix = process.platform;
227232
if (debugMode())
228233
testInfo.setTimeout(0);
@@ -243,58 +248,6 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
243248
}
244249
}, { auto: 'all-hooks-included', _title: 'context configuration' } as any],
245250

246-
_setupArtifacts: [async ({ playwright, screenshot }, use, testInfo) => {
247-
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot);
248-
await artifactsRecorder.willStartTest(testInfo as TestInfoImpl);
249-
const csiListener: ClientInstrumentationListener = {
250-
onApiCallBegin: (apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }) => {
251-
const testInfo = currentTestInfo();
252-
if (!testInfo || apiName.includes('setTestIdAttribute'))
253-
return { userObject: null };
254-
const step = testInfo._addStep({
255-
location: frames[0] as any,
256-
category: 'pw:api',
257-
title: renderApiCall(apiName, params),
258-
apiName,
259-
params,
260-
});
261-
userData.userObject = step;
262-
out.stepId = step.stepId;
263-
},
264-
onApiCallEnd: (userData: any, error?: Error) => {
265-
const step = userData.userObject;
266-
step?.complete({ error });
267-
},
268-
onWillPause: () => {
269-
currentTestInfo()?.setTimeout(0);
270-
},
271-
runAfterCreateBrowserContext: async (context: BrowserContext) => {
272-
await artifactsRecorder?.didCreateBrowserContext(context);
273-
const testInfo = currentTestInfo();
274-
if (testInfo)
275-
attachConnectedHeaderIfNeeded(testInfo, context.browser());
276-
},
277-
runAfterCreateRequestContext: async (context: APIRequestContext) => {
278-
await artifactsRecorder?.didCreateRequestContext(context);
279-
},
280-
runBeforeCloseBrowserContext: async (context: BrowserContext) => {
281-
await artifactsRecorder?.willCloseBrowserContext(context);
282-
},
283-
runBeforeCloseRequestContext: async (context: APIRequestContext) => {
284-
await artifactsRecorder?.willCloseRequestContext(context);
285-
},
286-
};
287-
288-
const clientInstrumentation = (playwright as any)._instrumentation as ClientInstrumentation;
289-
clientInstrumentation.addListener(csiListener);
290-
291-
await use();
292-
293-
clientInstrumentation.removeListener(csiListener);
294-
await artifactsRecorder.didFinishTest();
295-
296-
}, { auto: 'all-hooks-included', _title: 'trace recording' } as any],
297-
298251
_contextFactory: [async ({ browser, video, _reuseContext }, use, testInfo) => {
299252
const testInfoImpl = testInfo as TestInfoImpl;
300253
const videoMode = normalizeVideoMode(video);
@@ -471,7 +424,7 @@ class ArtifactsRecorder {
471424
private _playwright: Playwright;
472425
private _artifactsDir: string;
473426
private _screenshotMode: ScreenshotMode;
474-
private _screenshotOptions: { mode: ScreenshotMode } & Pick<playwrightLibrary.PageScreenshotOptions, 'fullPage' | 'omitBackground'> | undefined;
427+
private _screenshotOptions: { mode: ScreenshotMode } & Pick<PageScreenshotOptions, 'fullPage' | 'omitBackground'> | undefined;
475428
private _temporaryScreenshots: string[] = [];
476429
private _temporaryArtifacts: string[] = [];
477430
private _reusedContexts = new Set<BrowserContext>();
@@ -496,7 +449,6 @@ class ArtifactsRecorder {
496449

497450
async willStartTest(testInfo: TestInfoImpl) {
498451
this._testInfo = testInfo;
499-
testInfo._onDidFinishTestFunction = () => this.didFinishTestFunction();
500452

501453
// Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not
502454
// overwrite previous screenshots.
@@ -678,6 +630,101 @@ function tracing() {
678630
return (test.info() as TestInfoImpl)._tracing;
679631
}
680632

633+
class InstrumentationConnector implements TestLifecycleInstrumentation, ClientInstrumentationListener {
634+
private _playwright: PlaywrightWorkerArgs['playwright'] | undefined;
635+
private _screenshot: ScreenshotOption = 'off';
636+
private _artifactsRecorder: ArtifactsRecorder | undefined;
637+
private _testIsRunning = false;
638+
639+
constructor() {
640+
setTestLifecycleInstrumentation(this);
641+
}
642+
643+
async setPlaywright(playwright: PlaywrightWorkerArgs['playwright'] | undefined, screenshot: ScreenshotOption) {
644+
if (this._playwright) {
645+
if (this._testIsRunning) {
646+
// When "playwright" is destroyed during a test, collect artifacts immediately.
647+
await this.onTestEnd();
648+
}
649+
const clientInstrumentation = (this._playwright as any)._instrumentation as ClientInstrumentation;
650+
clientInstrumentation.removeListener(this);
651+
}
652+
this._playwright = playwright;
653+
this._screenshot = screenshot;
654+
if (this._playwright) {
655+
const clientInstrumentation = (this._playwright as any)._instrumentation as ClientInstrumentation;
656+
clientInstrumentation.addListener(this);
657+
if (this._testIsRunning) {
658+
// When "playwright" is created during a test, wire it up immediately.
659+
await this.onTestBegin();
660+
}
661+
}
662+
}
663+
664+
async onTestBegin() {
665+
this._testIsRunning = true;
666+
if (this._playwright) {
667+
this._artifactsRecorder = new ArtifactsRecorder(this._playwright, tracing().artifactsDir(), this._screenshot);
668+
await this._artifactsRecorder.willStartTest(currentTestInfo() as TestInfoImpl);
669+
}
670+
}
671+
672+
async onTestFunctionEnd() {
673+
await this._artifactsRecorder?.didFinishTestFunction();
674+
}
675+
676+
async onTestEnd() {
677+
await this._artifactsRecorder?.didFinishTest();
678+
this._artifactsRecorder = undefined;
679+
this._testIsRunning = false;
680+
}
681+
682+
onApiCallBegin(apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }) {
683+
const testInfo = currentTestInfo();
684+
if (!testInfo || apiName.includes('setTestIdAttribute'))
685+
return { userObject: null };
686+
const step = testInfo._addStep({
687+
location: frames[0] as any,
688+
category: 'pw:api',
689+
title: renderApiCall(apiName, params),
690+
apiName,
691+
params,
692+
});
693+
userData.userObject = step;
694+
out.stepId = step.stepId;
695+
}
696+
697+
onApiCallEnd(userData: any, error?: Error) {
698+
const step = userData.userObject;
699+
step?.complete({ error });
700+
}
701+
702+
onWillPause() {
703+
currentTestInfo()?.setTimeout(0);
704+
}
705+
706+
async runAfterCreateBrowserContext(context: BrowserContext) {
707+
await this._artifactsRecorder?.didCreateBrowserContext(context);
708+
const testInfo = currentTestInfo();
709+
if (testInfo)
710+
attachConnectedHeaderIfNeeded(testInfo, context.browser());
711+
}
712+
713+
async runAfterCreateRequestContext(context: APIRequestContext) {
714+
await this._artifactsRecorder?.didCreateRequestContext(context);
715+
}
716+
717+
async runBeforeCloseBrowserContext(context: BrowserContext) {
718+
await this._artifactsRecorder?.willCloseBrowserContext(context);
719+
}
720+
721+
async runBeforeCloseRequestContext(context: APIRequestContext) {
722+
await this._artifactsRecorder?.willCloseRequestContext(context);
723+
}
724+
}
725+
726+
const connector = new InstrumentationConnector();
727+
681728
export const test = _baseTest.extend<TestFixtures, WorkerFixtures>(playwrightFixtures);
682729

683730
export { defineConfig } from './common/configLoader';

packages/playwright/src/worker/testInfo.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ export class TestInfoImpl implements TestInfo {
6868
readonly _projectInternal: FullProjectInternal;
6969
readonly _configInternal: FullConfigInternal;
7070
private readonly _steps: TestStepInternal[] = [];
71-
_onDidFinishTestFunction: (() => Promise<void>) | undefined;
7271
private readonly _stages: TestStage[] = [];
7372
_hasNonRetriableError = false;
7473
_hasUnhandledError = false;

packages/playwright/src/worker/workerMain.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { colors } from 'playwright-core/lib/utilsBundle';
1818
import { debugTest, relativeFilePath, serializeError } from '../util';
1919
import { type TestBeginPayload, type TestEndPayload, type RunPayload, type DonePayload, type WorkerInitParams, type TeardownErrorsPayload, stdioChunkToParams } from '../common/ipc';
20-
import { setCurrentTestInfo, setIsWorkerProcess } from '../common/globals';
20+
import { setCurrentTestInfo, setIsWorkerProcess, testLifecycleInstrumentation } from '../common/globals';
2121
import { deserializeConfig } from '../common/configLoader';
2222
import type { Suite, TestCase } from '../common/test';
2323
import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config';
@@ -304,10 +304,11 @@ export class WorkerMain extends ProcessRunner {
304304
if (this._lastRunningTests.length > 10)
305305
this._lastRunningTests.shift();
306306
let shouldRunAfterEachHooks = false;
307+
const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 };
307308

308309
testInfo._allowSkips = true;
309310
await testInfo._runAsStage({ title: 'setup and test' }, async () => {
310-
await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test' } }, async () => {
311+
await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => {
311312
// Ideally, "trace" would be an config-level option belonging to the
312313
// test runner instead of a fixture belonging to Playwright.
313314
// However, for backwards compatibility, we have to read it from a fixture today.
@@ -318,6 +319,7 @@ export class WorkerMain extends ProcessRunner {
318319
if (typeof traceFixtureRegistration.fn === 'function')
319320
throw new Error(`"trace" option cannot be a function`);
320321
await testInfo._tracing.startIfNeeded(traceFixtureRegistration.fn);
322+
await testLifecycleInstrumentation()?.onTestBegin?.();
321323
});
322324

323325
if (this._isStopped || isSkipped) {
@@ -372,10 +374,10 @@ export class WorkerMain extends ProcessRunner {
372374

373375
try {
374376
// Run "immediately upon test function finish" callback.
375-
await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: afterHooksSlot } }, async () => testInfo._onDidFinishTestFunction?.());
377+
await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: tracingSlot } }, async () => {
378+
await testLifecycleInstrumentation()?.onTestFunctionEnd?.();
379+
});
376380
} catch (error) {
377-
if (error instanceof TimeoutManagerError)
378-
didTimeoutInAfterHooks = true;
379381
firstAfterHooksError = firstAfterHooksError ?? error;
380382
}
381383

@@ -458,8 +460,8 @@ export class WorkerMain extends ProcessRunner {
458460
}).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
459461
}
460462

461-
const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 };
462463
await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => {
464+
await testLifecycleInstrumentation()?.onTestEnd?.();
463465
await testInfo._tracing.stopIfNeeded();
464466
}).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
465467

tests/config/testModeFixtures.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,12 @@ export type TestModeTestFixtures = {
3030
export type TestModeWorkerFixtures = {
3131
toImplInWorkerScope: (rpcObject?: any) => any;
3232
playwright: typeof import('@playwright/test');
33+
_playwrightImpl: typeof import('@playwright/test');
3334
};
3435

3536
export const testModeTest = test.extend<TestModeTestFixtures, TestModeWorkerOptions & TestModeWorkerFixtures>({
3637
mode: ['default', { scope: 'worker', option: true }],
37-
playwright: [async ({ mode }, run) => {
38+
_playwrightImpl: [async ({ mode }, run) => {
3839
const testMode = {
3940
'default': new DefaultTestMode(),
4041
'service': new DefaultTestMode(),

tests/playwright-test/playwright.artifacts.spec.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,8 @@ test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => {
151151
' test-finished-1.png',
152152
'artifacts-shared-shared-failing',
153153
' test-failed-1.png',
154-
' test-failed-2.png',
155154
'artifacts-shared-shared-passing',
156155
' test-finished-1.png',
157-
' test-finished-2.png',
158156
'artifacts-two-contexts',
159157
' test-finished-1.png',
160158
' test-finished-2.png',
@@ -185,7 +183,6 @@ test('should work with screenshot: only-on-failure', async ({ runInlineTest }, t
185183
' test-failed-1.png',
186184
'artifacts-shared-shared-failing',
187185
' test-failed-1.png',
188-
' test-failed-2.png',
189186
'artifacts-two-contexts-failing',
190187
' test-failed-1.png',
191188
' test-failed-2.png',

0 commit comments

Comments
 (0)