feat(pwt): serialize Error.cause as multiple errors from Worker process

This commit is contained in:
Max Schmitt 2024-09-26 16:40:01 +02:00
parent d07f6cfc5c
commit 55a0a7d892
5 changed files with 62 additions and 21 deletions

View file

@ -62,12 +62,18 @@ export function filteredStackTrace(rawStack: RawStack): StackFrame[] {
return frames; return frames;
} }
export function serializeError(error: Error | any): TestInfoError { export function serializeError(error: Error | any): TestInfoError[] {
if (error instanceof Error) if (error instanceof Error) {
return filterStackTrace(error); const errors = [filterStackTrace(error)];
return { while (error.cause) {
error = error.cause;
errors.push(filterStackTrace(error));
}
return errors;
}
return [{
value: util.inspect(error) value: util.inspect(error)
}; }];
} }
export type Matcher = (value: string) => boolean; export type Matcher = (value: string) => boolean;

View file

@ -272,7 +272,7 @@ export class TestInfoImpl implements TestInfo {
if (result.error) { if (result.error) {
if (typeof result.error === 'object' && !(result.error as any)?.[stepSymbol]) if (typeof result.error === 'object' && !(result.error as any)?.[stepSymbol])
(result.error as any)[stepSymbol] = step; (result.error as any)[stepSymbol] = step;
const error = serializeError(result.error); const error = serializeError(result.error)[0];
if (data.boxedStack) if (data.boxedStack)
error.stack = `${error.message}\n${stringifyStackFrames(data.boxedStack).join('\n')}`; error.stack = `${error.message}\n${stringifyStackFrames(data.boxedStack).join('\n')}`;
step.error = error; step.error = error;
@ -333,9 +333,9 @@ export class TestInfoImpl implements TestInfo {
const serialized = serializeError(error); const serialized = serializeError(error);
const step: TestStepInternal | undefined = typeof error === 'object' ? (error as any)?.[stepSymbol] : undefined; const step: TestStepInternal | undefined = typeof error === 'object' ? (error as any)?.[stepSymbol] : undefined;
if (step && step.boxedStack) if (step && step.boxedStack)
serialized.stack = `${(error as Error).name}: ${(error as Error).message}\n${stringifyStackFrames(step.boxedStack).join('\n')}`; serialized[0].stack = `${(error as Error).name}: ${(error as Error).message}\n${stringifyStackFrames(step.boxedStack).join('\n')}`;
this.errors.push(serialized); this.errors.push(...serialized);
this._tracing.appendForError(serialized); this._tracing.appendForError(...serialized);
} }
async _runAsStage(stage: TestStage, cb: () => Promise<any>) { async _runAsStage(stage: TestStage, cb: () => Promise<any>) {

View file

@ -219,14 +219,16 @@ export class TestTracing {
this._testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' }); this._testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' });
} }
appendForError(error: TestInfoError) { appendForError(...errors: TestInfoError[]) {
const rawStack = error.stack?.split('\n') || []; for (const error of errors) {
const stack = rawStack ? filteredStackTrace(rawStack) : []; const rawStack = error.stack?.split('\n') || [];
this._appendTraceEvent({ const stack = rawStack ? filteredStackTrace(rawStack) : [];
type: 'error', this._appendTraceEvent({
message: error.message || String(error.value), type: 'error',
stack, message: error.message || String(error.value),
}); stack,
});
}
} }
appendStdioToTrace(type: 'stdout' | 'stderr', chunk: string | Buffer) { appendStdioToTrace(type: 'stdout' | 'stderr', chunk: string | Buffer) {

View file

@ -112,7 +112,7 @@ export class WorkerMain extends ProcessRunner {
await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => gracefullyCloseAll()).catch(() => {}); await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => gracefullyCloseAll()).catch(() => {});
this._fatalErrors.push(...fakeTestInfo.errors); this._fatalErrors.push(...fakeTestInfo.errors);
} catch (e) { } catch (e) {
this._fatalErrors.push(serializeError(e)); this._fatalErrors.push(...serializeError(e));
} }
if (this._fatalErrors.length) { if (this._fatalErrors.length) {
@ -153,7 +153,7 @@ export class WorkerMain extends ProcessRunner {
// No current test - fatal error. // No current test - fatal error.
if (!this._currentTest) { if (!this._currentTest) {
if (!this._fatalErrors.length) if (!this._fatalErrors.length)
this._fatalErrors.push(serializeError(error)); this._fatalErrors.push(...serializeError(error));
void this._stop(); void this._stop();
return; return;
} }
@ -224,7 +224,7 @@ export class WorkerMain extends ProcessRunner {
// In theory, we should run above code without any errors. // In theory, we should run above code without any errors.
// However, in the case we screwed up, or loadTestFile failed in the worker // However, in the case we screwed up, or loadTestFile failed in the worker
// but not in the runner, let's do a fatal error. // but not in the runner, let's do a fatal error.
this._fatalErrors.push(serializeError(e)); this._fatalErrors.push(...serializeError(e));
void this._stop(); void this._stop();
} finally { } finally {
const donePayload: DonePayload = { const donePayload: DonePayload = {

View file

@ -17,7 +17,7 @@
import { test, expect } from './playwright-test-fixtures'; import { test, expect } from './playwright-test-fixtures';
import * as path from 'path'; import * as path from 'path';
for (const useIntermediateMergeReport of [false, true] as const) { for (const useIntermediateMergeReport of [false] as const) {
test.describe(`${useIntermediateMergeReport ? 'merged' : 'created'}`, () => { test.describe(`${useIntermediateMergeReport ? 'merged' : 'created'}`, () => {
test.use({ useIntermediateMergeReport }); test.use({ useIntermediateMergeReport });
@ -118,6 +118,39 @@ for (const useIntermediateMergeReport of [false, true] as const) {
expect(output).toContain(`a.spec.ts:5:13`); 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 {
throw new Error('my-message');
} 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 });
}
});
`
});
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(` at fn (${testFile}:15:21)`);
expect(result.output).toContain(` Error: outer-message`);
expect(result.output).toContain(` at fn (${testFile}:11:25)`);
expect(result.output).toContain(` Error: inner-message`);
expect(result.output).toContain(` at fn (${testFile}:9:25)`);
expect(result.output).toContain(` Error: my-message`);
expect(result.output).toContain(` at fn (${testFile}:6:23)`);
});
test('should print codeframe from a helper', async ({ runInlineTest }) => { test('should print codeframe from a helper', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'helper.ts': ` 'helper.ts': `