import { afterEach, expect, test, vi } from "vitest" import { observeBrowserExceptions, onBrowserGlobalError, onBrowserUnhandledRejection, onBrowserWindowOnError, } from "#Source/exception/index.ts" class WindowMock extends EventTarget { onerror: OnErrorEventHandler | null = null document = {} } const createBrowserErrorEvent = (options: { message: string filename?: string | undefined lineno?: number | undefined colno?: number | undefined error?: unknown }): Event => { const event = new Event("error") Object.defineProperties(event, { message: { value: options.message, configurable: true }, filename: { value: options.filename, configurable: true }, lineno: { value: options.lineno, configurable: true }, colno: { value: options.colno, configurable: true }, error: { value: options.error, configurable: true }, }) return event } const createBrowserUnhandledRejectionEvent = (options: { reason: unknown promise: Promise }): Event => { const event = new Event("unhandledrejection") Object.defineProperties(event, { reason: { value: options.reason, configurable: true }, promise: { value: options.promise, configurable: true }, }) return event } afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() }) test("onBrowserGlobalError listens and cleans up window error events", () => { const browserWindow = new WindowMock() const listener = vi.fn() vi.stubGlobal("window", browserWindow) vi.stubGlobal("self", browserWindow) vi.stubGlobal("document", browserWindow.document) const cleanup = onBrowserGlobalError(listener) const event = createBrowserErrorEvent({ message: "boom", filename: "app.ts", lineno: 8, colno: 9, error: new Error("boom"), }) browserWindow.dispatchEvent(event) expect(listener).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ runtime: "browser", source: "browser.global-error", message: "boom", filename: "app.ts", lineno: 8, colno: 9, }), ) cleanup() browserWindow.dispatchEvent(event) expect(listener).toHaveBeenCalledTimes(1) }) test("onBrowserWindowOnError composes listeners and restores window.onerror after the last cleanup", () => { const browserWindow = new WindowMock() const previousOnError = vi.fn(() => false) const listener1 = vi.fn() const listener2 = vi.fn() // oxlint-disable-next-line prefer-add-event-listener browserWindow.onerror = previousOnError vi.stubGlobal("window", browserWindow) vi.stubGlobal("self", browserWindow) vi.stubGlobal("document", browserWindow.document) const cleanup1 = onBrowserWindowOnError(listener1) const cleanup2 = onBrowserWindowOnError(listener2) browserWindow.onerror?.("boom", "app.ts", 3, 5, new Error("boom")) expect(listener1).toHaveBeenCalledTimes(1) expect(listener1).toHaveBeenCalledWith( expect.objectContaining({ runtime: "browser", source: "browser.window-onerror", filename: "app.ts", lineno: 3, colno: 5, }), ) expect(listener2).toHaveBeenCalledTimes(1) expect(listener2).toHaveBeenCalledWith( expect.objectContaining({ runtime: "browser", source: "browser.window-onerror", filename: "app.ts", lineno: 3, colno: 5, }), ) expect(previousOnError).toHaveBeenCalledTimes(1) cleanup1() browserWindow.onerror?.("again", "next.ts", 7, 11, new Error("again")) expect(listener1).toHaveBeenCalledTimes(1) expect(listener2).toHaveBeenCalledTimes(2) expect(previousOnError).toHaveBeenCalledTimes(2) cleanup2() expect(browserWindow.onerror).toBe(previousOnError) }) test("onBrowserUnhandledRejection listens and cleans up promise rejection events", () => { const browserWindow = new WindowMock() const listener = vi.fn() vi.stubGlobal("window", browserWindow) vi.stubGlobal("self", browserWindow) vi.stubGlobal("document", browserWindow.document) const cleanup = onBrowserUnhandledRejection(listener) const promise = Promise.resolve("ok") const rejectionEvent = createBrowserUnhandledRejectionEvent({ promise, reason: new Error("reject"), }) browserWindow.dispatchEvent(rejectionEvent) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ runtime: "browser", source: "browser.unhandled-rejection", message: "reject", }), ) cleanup() browserWindow.dispatchEvent(rejectionEvent) expect(listener).toHaveBeenCalledTimes(1) }) test("observeBrowserExceptions listens to all browser sources by default and supports explicit opt-out", () => { const browserWindow = new WindowMock() const listener = vi.fn() vi.stubGlobal("window", browserWindow) vi.stubGlobal("self", browserWindow) vi.stubGlobal("document", browserWindow.document) const cleanup1 = observeBrowserExceptions(listener, { captureTimestamp: () => 777, }) const promise = Promise.resolve("ok") browserWindow.dispatchEvent( createBrowserErrorEvent({ message: "again", error: new Error("again"), }), ) browserWindow.onerror?.("window boom", "page.ts", 1, 2, new Error("window boom")) browserWindow.dispatchEvent( createBrowserUnhandledRejectionEvent({ promise, reason: "reason-text", }), ) expect(listener).toHaveBeenCalledTimes(3) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ source: "browser.global-error", timestamp: 777, }), ) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ source: "browser.window-onerror", timestamp: 777, filename: "page.ts", }), ) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ source: "browser.unhandled-rejection", timestamp: 777, message: "reason-text", }), ) cleanup1() listener.mockClear() const cleanup2 = observeBrowserExceptions(listener, { includeWindowOnError: false, }) browserWindow.dispatchEvent( createBrowserErrorEvent({ message: "still-on", error: new Error("still-on"), }), ) browserWindow.onerror?.("ignored", "page.ts", 1, 2, new Error("ignored")) browserWindow.dispatchEvent( createBrowserUnhandledRejectionEvent({ promise, reason: "still-on", }), ) expect(listener).toHaveBeenCalledTimes(2) expect(listener).not.toHaveBeenCalledWith( expect.objectContaining({ source: "browser.window-onerror", }), ) cleanup2() })