diff --git a/docs/src/test-api/class-testinfoerror.md b/docs/src/test-api/class-testinfoerror.md index 66e78ecabd..eadcaff0fd 100644 --- a/docs/src/test-api/class-testinfoerror.md +++ b/docs/src/test-api/class-testinfoerror.md @@ -4,6 +4,12 @@ Information about an error thrown during test execution. +## property: TestInfoError.cause +* since: v1.49 +- type: ?<[TestInfoError]> + +Error cause. Set when there is a [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error]. + ## property: TestInfoError.message * since: v1.10 - type: ?<[string]> diff --git a/docs/src/test-reporter-api/class-testerror.md b/docs/src/test-reporter-api/class-testerror.md index 7a872c63fc..10414f8574 100644 --- a/docs/src/test-reporter-api/class-testerror.md +++ b/docs/src/test-reporter-api/class-testerror.md @@ -4,6 +4,12 @@ Information about an error thrown during test execution. +## property: TestError.cause +* since: v1.49 +- type: ?<[TestError]> + +Error cause. Set when there is a [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error]. + ## property: TestError.message * since: v1.10 - type: ?<[string]> diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index fb0930c545..9bb5da81f6 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -434,15 +434,16 @@ export function formatError(error: TestError, highlightCode: boolean): ErrorDeta tokens.push(snippet); } - if (parsedStack && parsedStack.stackLines.length) { - tokens.push(''); + if (parsedStack && parsedStack.stackLines.length) tokens.push(colors.dim(parsedStack.stackLines.join('\n'))); - } let location = error.location; if (parsedStack && !location) location = parsedStack.location; + if (error.cause) + tokens.push(colors.dim('[cause]: ') + formatError(error.cause, highlightCode).message); + return { location, message: tokens.join('\n'), diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 4046809433..f7f91d3198 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -29,15 +29,17 @@ import type { TestInfoErrorImpl } from './common/ipc'; const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..'); const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core/package.json')); -export function filterStackTrace(e: Error): { message: string, stack: string } { +export function filterStackTrace(e: Error): { message: string, stack: string, cause?: ReturnType } { const name = e.name ? e.name + ': ' : ''; + const cause = e.cause instanceof Error ? filterStackTrace(e.cause) : undefined; if (process.env.PWDEBUGIMPL) - return { message: name + e.message, stack: e.stack || '' }; + return { message: name + e.message, stack: e.stack || '', cause }; const stackLines = stringifyStackFrames(filteredStackTrace(e.stack?.split('\n') || [])); return { message: name + e.message, - stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}` + stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}`, + cause, }; } diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 5e7a3d80db..eb0ce9d807 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -224,11 +224,18 @@ export class TestTracing { const stack = rawStack ? filteredStackTrace(rawStack) : []; this._appendTraceEvent({ type: 'error', - message: error.message || String(error.value), + message: this._formatError(error), stack, }); } + _formatError(error: TestInfoErrorImpl) { + const parts: string[] = [error.message || String(error.value)]; + if (error.cause) + parts.push('[cause]: ' + this._formatError(error.cause)); + return parts.join('\n'); + } + appendStdioToTrace(type: 'stdout' | 'stderr', chunk: string | Buffer) { this._appendTraceEvent({ type, diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 5db30f72e2..706d567dcb 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -9152,6 +9152,13 @@ export interface TestInfo { * Information about an error thrown during test execution. */ export interface TestInfoError { + /** + * Error cause. Set when there is a + * [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the + * error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error]. + */ + cause?: TestInfoError; + /** * Error message. Set when [Error] (or its subclass) has been thrown. */ diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts index a9d1f020ae..04cf03287f 100644 --- a/packages/playwright/types/testReporter.d.ts +++ b/packages/playwright/types/testReporter.d.ts @@ -554,6 +554,13 @@ export interface TestCase { * Information about an error thrown during test execution. */ export interface TestError { + /** + * Error cause. Set when there is a + * [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the + * error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error]. + */ + cause?: TestError; + /** * Error location in the source code. */ diff --git a/tests/playwright-test/reporter-base.spec.ts b/tests/playwright-test/reporter-base.spec.ts index 85ba731bf5..780739eb83 100644 --- a/tests/playwright-test/reporter-base.spec.ts +++ b/tests/playwright-test/reporter-base.spec.ts @@ -118,6 +118,53 @@ for (const useIntermediateMergeReport of [false, true] as const) { expect(output).toContain(`a.spec.ts:5:13`); }); + test('should print error with a nested cause', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + + test('foobar', async ({}) => { + try { + try { + const error = new Error('my-message'); + error.name = 'SpecialError'; + throw error; + } catch (e) { + try { + throw new Error('inner-message', { cause: e }); + } catch (e) { + throw new Error('outer-message', { cause: e }); + } + } + } catch (e) { + throw new Error('wrapper-message', { cause: e }); + } + }); + test.afterAll(() => { + expect(test.info().errors.length).toBe(1); + expect(test.info().errors[0]).toBe(test.info().error); + expect(test.info().error.message).toBe('Error: wrapper-message'); + expect(test.info().error.cause.message).toBe('Error: outer-message'); + expect(test.info().error.cause.cause.message).toBe('Error: inner-message'); + expect(test.info().error.cause.cause.cause.message).toBe('SpecialError: my-message'); + expect(test.info().error.cause.cause.cause.cause).toBe(undefined); + console.log('afterAll executed successfully'); + }) + ` + }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + const testFile = path.join(result.report.config.rootDir, result.report.suites[0].specs[0].file); + expect(result.output).toContain(`${testFile}:18:21`); + expect(result.output).toContain(`[cause]: Error: outer-message`); + expect(result.output).toContain(`${testFile}:14:25`); + expect(result.output).toContain(`[cause]: Error: inner-message`); + expect(result.output).toContain(`${testFile}:12:25`); + expect(result.output).toContain(`[cause]: SpecialError: my-message`); + expect(result.output).toContain(`${testFile}:7:31`); + expect(result.output).toContain('afterAll executed successfully'); + }); + test('should print codeframe from a helper', async ({ runInlineTest }) => { const result = await runInlineTest({ 'helper.ts': `