fix(test runner): screenshot immediately after failure (#15159)
Previously, screenshot was taken after hooks and fixtures teardown. However, hooks can easily modify the state of the page, and screenshot would not reflect the moment of failure. Instead, we take screenshots immediately after the test function finishes with an error.
This commit is contained in:
parent
857d46ca93
commit
79163e802a
|
|
@ -25,6 +25,7 @@ export { expect } from './expect';
|
||||||
export const _baseTest: TestType<{}, {}> = rootTestType.test;
|
export const _baseTest: TestType<{}, {}> = rootTestType.test;
|
||||||
export { addRunnerPlugin as _addRunnerPlugin } from './plugins';
|
export { addRunnerPlugin as _addRunnerPlugin } from './plugins';
|
||||||
import * as outOfProcess from 'playwright-core/lib/outofprocess';
|
import * as outOfProcess from 'playwright-core/lib/outofprocess';
|
||||||
|
import type { TestInfoImpl } from './testInfo';
|
||||||
|
|
||||||
if ((process as any)['__pw_initiator__']) {
|
if ((process as any)['__pw_initiator__']) {
|
||||||
const originalStackTraceLimit = Error.stackTraceLimit;
|
const originalStackTraceLimit = Error.stackTraceLimit;
|
||||||
|
|
@ -249,6 +250,7 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||||
const temporaryTraceFiles: string[] = [];
|
const temporaryTraceFiles: string[] = [];
|
||||||
const temporaryScreenshots: string[] = [];
|
const temporaryScreenshots: string[] = [];
|
||||||
const createdContexts = new Set<BrowserContext>();
|
const createdContexts = new Set<BrowserContext>();
|
||||||
|
const testInfoImpl = testInfo as TestInfoImpl;
|
||||||
|
|
||||||
const createInstrumentationListener = (context?: BrowserContext) => {
|
const createInstrumentationListener = (context?: BrowserContext) => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -260,9 +262,8 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||||
context?.setDefaultNavigationTimeout(0);
|
context?.setDefaultNavigationTimeout(0);
|
||||||
context?.setDefaultTimeout(0);
|
context?.setDefaultTimeout(0);
|
||||||
}
|
}
|
||||||
const testInfoImpl = testInfo as any;
|
|
||||||
const step = testInfoImpl._addStep({
|
const step = testInfoImpl._addStep({
|
||||||
location: stackTrace?.frames[0],
|
location: stackTrace?.frames[0] as any,
|
||||||
category: 'pw:api',
|
category: 'pw:api',
|
||||||
title: apiCall,
|
title: apiCall,
|
||||||
canHaveChildren: false,
|
canHaveChildren: false,
|
||||||
|
|
@ -320,16 +321,29 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const screenshottedSymbol = Symbol('screenshotted');
|
||||||
|
const screenshotPage = async (page: Page) => {
|
||||||
|
if ((page as any)[screenshottedSymbol])
|
||||||
|
return;
|
||||||
|
(page as any)[screenshottedSymbol] = true;
|
||||||
|
const screenshotPath = path.join(_artifactsDir(), createGuid() + '.png');
|
||||||
|
temporaryScreenshots.push(screenshotPath);
|
||||||
|
await page.screenshot({ timeout: 5000, path: screenshotPath }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const screenshotOnTestFailure = async () => {
|
||||||
|
const contexts: BrowserContext[] = [];
|
||||||
|
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit])
|
||||||
|
contexts.push(...(browserType as any)._contexts);
|
||||||
|
await Promise.all(contexts.map(ctx => Promise.all(ctx.pages().map(screenshotPage))));
|
||||||
|
};
|
||||||
|
|
||||||
const onWillCloseContext = async (context: BrowserContext) => {
|
const onWillCloseContext = async (context: BrowserContext) => {
|
||||||
await stopTracing(context.tracing);
|
await stopTracing(context.tracing);
|
||||||
if (screenshot === 'on' || screenshot === 'only-on-failure') {
|
if (screenshot === 'on' || screenshot === 'only-on-failure') {
|
||||||
// Capture screenshot for now. We'll know whether we have to preserve them
|
// Capture screenshot for now. We'll know whether we have to preserve them
|
||||||
// after the test finishes.
|
// after the test finishes.
|
||||||
await Promise.all(context.pages().map(async page => {
|
await Promise.all(context.pages().map(screenshotPage));
|
||||||
const screenshotPath = path.join(_artifactsDir(), createGuid() + '.png');
|
|
||||||
temporaryScreenshots.push(screenshotPath);
|
|
||||||
await page.screenshot({ timeout: 5000, path: screenshotPath }).catch(() => {});
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -352,6 +366,8 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||||
const existingApiRequests: APIRequestContext[] = Array.from((playwright.request as any)._contexts as Set<APIRequestContext>);
|
const existingApiRequests: APIRequestContext[] = Array.from((playwright.request as any)._contexts as Set<APIRequestContext>);
|
||||||
await Promise.all(existingApiRequests.map(onDidCreateRequestContext));
|
await Promise.all(existingApiRequests.map(onDidCreateRequestContext));
|
||||||
}
|
}
|
||||||
|
if (screenshot === 'on' || screenshot === 'only-on-failure')
|
||||||
|
testInfoImpl._onTestFailureImmediateCallbacks.set(screenshotOnTestFailure, 'Screenshot on failure');
|
||||||
|
|
||||||
// 2. Run the test.
|
// 2. Run the test.
|
||||||
await use();
|
await use();
|
||||||
|
|
@ -391,6 +407,7 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||||
const leftoverApiRequests: APIRequestContext[] = Array.from((playwright.request as any)._contexts as Set<APIRequestContext>);
|
const leftoverApiRequests: APIRequestContext[] = Array.from((playwright.request as any)._contexts as Set<APIRequestContext>);
|
||||||
(playwright.request as any)._onDidCreateContext = undefined;
|
(playwright.request as any)._onDidCreateContext = undefined;
|
||||||
(playwright.request as any)._onWillCloseContext = undefined;
|
(playwright.request as any)._onWillCloseContext = undefined;
|
||||||
|
testInfoImpl._onTestFailureImmediateCallbacks.delete(screenshotOnTestFailure);
|
||||||
|
|
||||||
const stopTraceChunk = async (tracing: Tracing): Promise<boolean> => {
|
const stopTraceChunk = async (tracing: Tracing): Promise<boolean> => {
|
||||||
// When we timeout during context.close(), we might end up with context still alive
|
// When we timeout during context.close(), we might end up with context still alive
|
||||||
|
|
@ -409,8 +426,13 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||||
await Promise.all(leftoverContexts.map(async context => {
|
await Promise.all(leftoverContexts.map(async context => {
|
||||||
if (!await stopTraceChunk(context.tracing))
|
if (!await stopTraceChunk(context.tracing))
|
||||||
return;
|
return;
|
||||||
if (captureScreenshots)
|
if (captureScreenshots) {
|
||||||
await Promise.all(context.pages().map(page => page.screenshot({ timeout: 5000, path: addScreenshotAttachment() }).catch(() => {})));
|
await Promise.all(context.pages().map(async page => {
|
||||||
|
if ((page as any)[screenshottedSymbol])
|
||||||
|
return;
|
||||||
|
await page.screenshot({ timeout: 5000, path: addScreenshotAttachment() }).catch(() => {});
|
||||||
|
}));
|
||||||
|
}
|
||||||
}).concat(leftoverApiRequests.map(async context => {
|
}).concat(leftoverApiRequests.map(async context => {
|
||||||
const tracing = (context as any)._tracing as Tracing;
|
const tracing = (context as any)._tracing as Tracing;
|
||||||
await stopTraceChunk(tracing);
|
await stopTraceChunk(tracing);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export class TestInfoImpl implements TestInfo {
|
||||||
readonly _startWallTime: number;
|
readonly _startWallTime: number;
|
||||||
private _hasHardError: boolean = false;
|
private _hasHardError: boolean = false;
|
||||||
readonly _screenshotsDir: string;
|
readonly _screenshotsDir: string;
|
||||||
|
readonly _onTestFailureImmediateCallbacks = new Map<() => Promise<void>, string>(); // fn -> title
|
||||||
|
|
||||||
// ------------ TestInfo fields ------------
|
// ------------ TestInfo fields ------------
|
||||||
readonly repeatEachIndex: number;
|
readonly repeatEachIndex: number;
|
||||||
|
|
@ -224,6 +225,10 @@ export class TestInfoImpl implements TestInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isFailure() {
|
||||||
|
return this.status !== 'skipped' && this.status !== this.expectedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------ TestInfo methods ------------
|
// ------------ TestInfo methods ------------
|
||||||
|
|
||||||
async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) {
|
async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) {
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,22 @@ export class WorkerRunner extends EventEmitter {
|
||||||
// Note: do not wrap all teardown steps together, because failure in any of them
|
// Note: do not wrap all teardown steps together, because failure in any of them
|
||||||
// does not prevent further teardown steps from running.
|
// does not prevent further teardown steps from running.
|
||||||
|
|
||||||
|
// Run "immediately upon test failure" callbacks.
|
||||||
|
if (testInfo._isFailure()) {
|
||||||
|
const onFailureError = await testInfo._runFn(async () => {
|
||||||
|
testInfo._timeoutManager.setCurrentRunnable({ type: 'test', slot: afterHooksSlot });
|
||||||
|
for (const [fn, title] of testInfo._onTestFailureImmediateCallbacks) {
|
||||||
|
await testInfo._runAsStep(fn, {
|
||||||
|
category: 'hook',
|
||||||
|
title,
|
||||||
|
canHaveChildren: true,
|
||||||
|
forceNoParent: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
firstAfterHooksError = firstAfterHooksError || onFailureError;
|
||||||
|
}
|
||||||
|
|
||||||
// Run "afterEach" hooks, unless we failed at beforeAll stage.
|
// Run "afterEach" hooks, unless we failed at beforeAll stage.
|
||||||
if (shouldRunAfterEachHooks) {
|
if (shouldRunAfterEachHooks) {
|
||||||
const afterEachError = await testInfo._runFn(() => this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo, afterHooksSlot));
|
const afterEachError = await testInfo._runFn(() => this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo, afterHooksSlot));
|
||||||
|
|
@ -395,9 +411,8 @@ export class WorkerRunner extends EventEmitter {
|
||||||
const nextSuites = new Set(getSuites(nextTest));
|
const nextSuites = new Set(getSuites(nextTest));
|
||||||
// In case of failure the worker will be stopped and we have to make sure that afterAll
|
// In case of failure the worker will be stopped and we have to make sure that afterAll
|
||||||
// hooks run before test fixtures teardown.
|
// hooks run before test fixtures teardown.
|
||||||
const isFailure = testInfo.status !== 'skipped' && testInfo.status !== testInfo.expectedStatus;
|
|
||||||
for (const suite of reversedSuites) {
|
for (const suite of reversedSuites) {
|
||||||
if (!nextSuites.has(suite) || isFailure) {
|
if (!nextSuites.has(suite) || testInfo._isFailure()) {
|
||||||
const afterAllError = await this._runAfterAllHooksForSuite(suite, testInfo);
|
const afterAllError = await this._runAfterAllHooksForSuite(suite, testInfo);
|
||||||
firstAfterHooksError = firstAfterHooksError || afterAllError;
|
firstAfterHooksError = firstAfterHooksError || afterAllError;
|
||||||
}
|
}
|
||||||
|
|
@ -409,8 +424,7 @@ export class WorkerRunner extends EventEmitter {
|
||||||
firstAfterHooksError = firstAfterHooksError || testScopeError;
|
firstAfterHooksError = firstAfterHooksError || testScopeError;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFailure = testInfo.status !== 'skipped' && testInfo.status !== testInfo.expectedStatus;
|
if (testInfo._isFailure())
|
||||||
if (isFailure)
|
|
||||||
this._isStopped = true;
|
this._isStopped = true;
|
||||||
|
|
||||||
if (this._isStopped) {
|
if (this._isStopped) {
|
||||||
|
|
@ -439,7 +453,7 @@ export class WorkerRunner extends EventEmitter {
|
||||||
this.emit('testEnd', buildTestEndPayload(testInfo));
|
this.emit('testEnd', buildTestEndPayload(testInfo));
|
||||||
|
|
||||||
const preserveOutput = this._loader.fullConfig().preserveOutput === 'always' ||
|
const preserveOutput = this._loader.fullConfig().preserveOutput === 'always' ||
|
||||||
(this._loader.fullConfig().preserveOutput === 'failures-only' && isFailure);
|
(this._loader.fullConfig().preserveOutput === 'failures-only' && testInfo._isFailure());
|
||||||
if (!preserveOutput)
|
if (!preserveOutput)
|
||||||
await removeFolderAsync(testInfo.outputDir).catch(e => {});
|
await removeFolderAsync(testInfo.outputDir).catch(e => {});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -278,3 +278,26 @@ test('should work with trace: on-first-retry', async ({ runInlineTest }, testInf
|
||||||
'report.json',
|
'report.json',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should take screenshot when page is closed in afterEach', async ({ runInlineTest }, testInfo) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'playwright.config.ts': `
|
||||||
|
module.exports = { use: { screenshot: 'on' } };
|
||||||
|
`,
|
||||||
|
'a.spec.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails', async ({ page }) => {
|
||||||
|
expect(1).toBe(2);
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-fails', 'test-failed-1.png'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue