diff --git a/packages/playwright-core/src/client/errors.ts b/packages/playwright-core/src/client/errors.ts index 7e377025142ed..27bb6734bf605 100644 --- a/packages/playwright-core/src/client/errors.ts +++ b/packages/playwright-core/src/client/errors.ts @@ -15,7 +15,7 @@ */ import { isError } from '@isomorphic/rtti'; -import { parseSerializedValue, serializeValue } from '@protocol/serializers'; +import { parseSerializedValue, parseSystemErrorFields, serializeSystemErrorFields, serializeValue, systemErrorMessage } from '@protocol/serializers'; import type { SerializedError } from './channels'; @@ -51,7 +51,7 @@ export function isTargetClosedError(error: Error) { export function serializeError(e: any): SerializedError { if (isError(e)) - return { error: { message: e.message, stack: e.stack, name: e.name } }; + return { error: { message: systemErrorMessage(e), stack: e.stack, name: e.name, ...serializeSystemErrorFields(e) } }; return { value: serializeValue(e, value => ({ fallThrough: value })) }; } @@ -71,5 +71,6 @@ export function parseError(error: SerializedError): PlaywrightError { else e = Object.assign(new PlaywrightError(error.error.message), { name: error.error.name }); e.stack = error.error.stack || ''; + parseSystemErrorFields(error.error, e); return e; } diff --git a/packages/playwright-core/src/server/errors.ts b/packages/playwright-core/src/server/errors.ts index e30e3be9e1ecf..d749aba7cd50d 100644 --- a/packages/playwright-core/src/server/errors.ts +++ b/packages/playwright-core/src/server/errors.ts @@ -15,7 +15,7 @@ */ import { isError } from '@isomorphic/rtti'; -import { parseSerializedValue, serializeValue } from '@protocol/serializers'; +import { parseSerializedValue, parseSystemErrorFields, serializeSystemErrorFields, serializeValue, systemErrorMessage } from '@protocol/serializers'; import type { SerializedError } from './channels'; @@ -50,7 +50,7 @@ export function isTargetClosedError(error: Error) { export function serializeError(e: any): SerializedError { if (isError(e)) - return { error: { message: e.message, stack: e.stack, name: e.name } }; + return { error: { message: systemErrorMessage(e), stack: e.stack, name: e.name, ...serializeSystemErrorFields(e) } }; return { value: serializeValue(e, value => ({ fallThrough: value })) }; } @@ -63,5 +63,6 @@ export function parseError(error: SerializedError): Error { const e = new Error(error.error.message); e.stack = error.error.stack || ''; e.name = error.error.name; + parseSystemErrorFields(error.error, e); return e; } diff --git a/packages/protocol/spec/serialized.yml b/packages/protocol/spec/serialized.yml index 5a1ddb22d0431..e40ee4803ce45 100644 --- a/packages/protocol/spec/serialized.yml +++ b/packages/protocol/spec/serialized.yml @@ -104,4 +104,11 @@ SerializedError: message: string name: string stack: string? + # See https://nodejs.org/api/errors.html#class-systemerror + code: string? + errno: int? + syscall: string? + address: string? + port: int? + hostname: string? value: SerializedValue? diff --git a/packages/protocol/src/serializers.ts b/packages/protocol/src/serializers.ts index 399de41e35575..0b245c1b8f24c 100644 --- a/packages/protocol/src/serializers.ts +++ b/packages/protocol/src/serializers.ts @@ -14,7 +14,42 @@ * limitations under the License. */ -import type { SerializedValue } from '@protocol/structs'; +import type { SerializedError, SerializedValue } from '@protocol/structs'; + +type SerializedSystemError = NonNullable; + +// Structured fields of a Node.js system error, see https://nodejs.org/api/errors.html#class-systemerror. +const systemErrorFields: { [key in keyof SerializedSystemError]?: 'string' | 'number' } = { + code: 'string', + errno: 'number', + syscall: 'string', + address: 'string', + port: 'number', + hostname: 'string', +}; + +export function serializeSystemErrorFields(error: any): Partial { + const result: any = {}; + for (const [field, type] of Object.entries(systemErrorFields)) { + if (typeof error[field] === type) + result[field] = error[field]; + } + return result; +} + +export function parseSystemErrorFields(error: SerializedSystemError, target: Error) { + for (const [field, type] of Object.entries(systemErrorFields)) { + if (typeof (error as any)[field] === type) + (target as any)[field] = (error as any)[field]; + } +} + +export function systemErrorMessage(error: any): string { + const message = error.message; + if (typeof error.code !== 'string' || !/^E[A-Z0-9]+$/.test(error.code) || message.includes(error.code)) + return message; + return `${message} (${error.code})`; +} export function parseSerializedValue(value: SerializedValue, handles: any[] | undefined): any { return innerParseSerializedValue(value, handles, new Map(), []); diff --git a/packages/protocol/src/structs.d.ts b/packages/protocol/src/structs.d.ts index 9c637e7398574..c0d18a27cf1f2 100644 --- a/packages/protocol/src/structs.d.ts +++ b/packages/protocol/src/structs.d.ts @@ -314,6 +314,12 @@ export type SerializedError = { message: string, name: string, stack?: string, + code?: string, + errno?: number, + syscall?: string, + address?: string, + port?: number, + hostname?: string, }, value?: SerializedValue, }; diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 565a6a18cf956..3b45fd4e05448 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -2998,6 +2998,12 @@ scheme.SerializedError = tObject({ message: tString, name: tString, stack: tOptional(tString), + code: tOptional(tString), + errno: tOptional(tInt), + syscall: tOptional(tString), + address: tOptional(tString), + port: tOptional(tInt), + hostname: tOptional(tString), })), value: tOptional(tType('SerializedValue')), }); diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index 87254e84f8b05..fffd04cb1e45a 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -719,6 +719,32 @@ it('should retry ECONNRESET', { await request.dispose(); }); +it('should expose node error fields on network errors', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42532' } +}, async ({ playwright, server }) => { + // Reset the connection so that node reports a system error instead of a plain 'socket hang up'. + server.setRoute('/reset', req => req.socket.resetAndDestroy()); + const request = await playwright.request.newContext(); + const error = await request.get(server.PREFIX + '/reset', { maxRetries: 0 }).catch(e => e); + expect(error.message).toContain('ECONNRESET'); + expect(error.code).toBe('ECONNRESET'); + expect(error.syscall).toBe('read'); + expect(typeof error.errno).toBe('number'); + await request.dispose(); +}); + +it('should append node error code to the message', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42532' } +}, async ({ playwright, server }) => { + // Node reports this one as a plain 'socket hang up' that does not mention the code. + server.setRoute('/hangup', req => req.socket.destroy()); + const request = await playwright.request.newContext(); + const error = await request.get(server.PREFIX + '/hangup', { maxRetries: 0 }).catch(e => e); + expect(error.message).toContain('socket hang up (ECONNRESET)'); + expect(error.code).toBe('ECONNRESET'); + await request.dispose(); +}); + it('should not crash when server refuses body before reading it', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42074' } }, async ({ playwright, server }) => {