diff --git a/src/EventSource.ts b/src/EventSource.ts index 779740a..476b3b5 100644 --- a/src/EventSource.ts +++ b/src/EventSource.ts @@ -1,6 +1,6 @@ import {createParser, type EventSourceMessage, type EventSourceParser} from 'eventsource-parser' -import {ErrorEvent, syntaxError} from './errors.js' +import {ErrorEvent, flattenError, syntaxError} from './errors.js' import type { AddEventListenerOptions, EventListenerOptions, @@ -438,7 +438,7 @@ export class EventSource extends EventTarget { return } - this.#scheduleReconnect() + this.#scheduleReconnect(flattenError(err)) } /** @@ -514,6 +514,7 @@ export class EventSource extends EventTarget { * Handles the process referred to in the EventSource specification as "failing a connection". * * @param error - The error causing the connection to fail + * @param code - The HTTP status code, if available * @internal */ #failConnection(error?: string, code?: number) { @@ -525,14 +526,11 @@ export class EventSource extends EventTarget { // [spec] …and fires an event named `error` at the `EventSource` object. // [spec] Once the user agent has failed the connection, it does not attempt to reconnect. - const errorEvent = new ErrorEvent('error') - // [spec] > Implementations are especially encouraged to report detailed information // [spec] > to their development consoles whenever an error event is fired, since little // [spec] > to no information can be made available in the events themselves. // Printing to console is not very programatically helpful, though, so we emit a custom event. - errorEvent.code = code - errorEvent.message = error + const errorEvent = new ErrorEvent('error', code, error) this.#onError?.(errorEvent) this.dispatchEvent(errorEvent) @@ -541,9 +539,11 @@ export class EventSource extends EventTarget { /** * Schedules a reconnection attempt against the EventSource endpoint. * + * @param error - The error causing the connection to fail + * @param code - The HTTP status code, if available * @internal */ - #scheduleReconnect() { + #scheduleReconnect(error?: string, code?: number) { // [spec] If the readyState attribute is set to CLOSED, abort the task. if (this.#readyState === this.CLOSED) { return @@ -553,7 +553,7 @@ export class EventSource extends EventTarget { this.#readyState = this.CONNECTING // [spec] Fire an event named `error` at the EventSource object. - const errorEvent = new ErrorEvent('error') + const errorEvent = new ErrorEvent('error', code, error) this.#onError?.(errorEvent) this.dispatchEvent(errorEvent) diff --git a/src/errors.ts b/src/errors.ts index eb79470..18a7ec0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -21,6 +21,12 @@ export class ErrorEvent extends Event { * @public */ public message?: string | undefined + + constructor(type: string, code?: number, message?: string) { + super(type) + this.code = code ?? undefined + this.message = message ?? undefined + } } /** @@ -43,3 +49,27 @@ export function syntaxError(message: string): SyntaxError { return new SyntaxError(message) } + +/** + * Flatten an error into a single error message string. + * Unwraps nested errors and joins them with a comma. + * + * @param err - The error to flatten + * @returns A string representation of the error + * @internal + */ +export function flattenError(err: unknown): string { + if (!(err instanceof Error)) { + return `${err}` + } + + if ('errors' in err && Array.isArray(err.errors)) { + return err.errors.map(flattenError).join(', ') + } + + if ('cause' in err && err.cause instanceof Error) { + return `${err}: ${flattenError(err.cause)}` + } + + return err.message +} diff --git a/test/helpers.ts b/test/helpers.ts index 8e29db6..81070aa 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -7,6 +7,7 @@ type MessageReceiver = SinonSpy & { } const TYPE_ASSERTER = Symbol.for('waffle.type-asserter') +const PATTERN_ASSERTER = Symbol.for('waffle.pattern-asserter') export class ExpectationError extends Error { type = 'ExpectationError' @@ -146,6 +147,38 @@ export function expect( return } + if ( + typeof expected[key] === 'object' && + expected[key] !== null && + PATTERN_ASSERTER in expected[key] + ) { + if (typeof thing[key] !== 'string') { + throw new ExpectationError( + `Expected key "${key}" of ${descriptor || 'object'} to be a string, got ${typeof thing[key]}`, + ) + } + + if (typeof expected[key][PATTERN_ASSERTER] === 'string') { + if (!thing[key].includes(expected[key][PATTERN_ASSERTER])) { + throw new ExpectationError( + `Expected key "${key}" of ${descriptor || 'object'} to include "${expected[key][PATTERN_ASSERTER]}", got "${thing[key]}"`, + ) + } + return + } + + if (expected[key][PATTERN_ASSERTER] instanceof RegExp) { + if (!expected[key][PATTERN_ASSERTER].test(thing[key])) { + throw new ExpectationError( + `Expected key "${key}" of ${descriptor || 'object'} to match pattern ${expected[key][PATTERN_ASSERTER]}, got "${thing[key]}"`, + ) + } + return + } + + throw new Error('Invalid pattern asserter') + } + if (thing[key] !== expected[key]) { throw new ExpectationError( `Expected key "${key}" of ${descriptor || 'object'} to be ${JSON.stringify(expected[key])}, was ${JSON.stringify( @@ -188,6 +221,12 @@ expect.any = ( } } +expect.stringMatching = (expected: string | RegExp) => { + return { + [PATTERN_ASSERTER]: expected, + } +} + function isPlainObject(obj: unknown): obj is Record { return typeof obj === 'object' && obj !== null && !Array.isArray(obj) } diff --git a/test/tests.ts b/test/tests.ts index c128da0..f73a960 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -697,7 +697,27 @@ export function registerTests(options: { await deferClose(es) }) - test('[NON-SPEC] message event contains extended properties', async () => { + test('[NON-SPEC] message event contains extended properties (failed connection)', async () => { + const onError = getCallCounter({name: 'onError'}) + const es = new OurEventSource(`${baseUrl}:9999/should-not-connect`, {fetch}) + + es.addEventListener('error', onError) + await onError.waitForCallCount(1) + + expect(onError.lastCall.lastArg).toMatchObject({ + type: 'error', + defaultPrevented: false, + cancelable: false, + timeStamp: expect.any('number'), + message: expect.stringMatching( + /fetch failed|failed to fetch|load failed|attempting to fetch/i, + ), + code: undefined, + }) + await deferClose(es) + }) + + test('[NON-SPEC] message event contains extended properties (invalid http response)', async () => { const onError = getCallCounter({name: 'onError'}) const es = new OurEventSource(`${baseUrl}:${port}/end-after-one`, {fetch})