From f26ede7151576ac06ee95c64c790a38bc63e3ac9 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 1 Jun 2026 22:01:25 +0530 Subject: [PATCH] feat(web-runtime): capture uncaught errors into state.exceptions --- pkg/spec/src/web-runtime.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 4926985..17cc05e 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -316,6 +316,36 @@ function buildAx(): unknown { }; } +// Uncaught errors and unhandled rejections are buffered here as they fire, so +// the default noUncaughtExceptions property can observe them. Without this the +// web state.exceptions would always be empty and a page that throws would +// silently pass. +interface CapturedException { + class: string; + message: string; + stackTrace: string; + unixMillis: number; +} + +const capturedExceptions: CapturedException[] = []; + +function recordException(error: unknown): void { + const asError = error instanceof Error ? error : undefined; + capturedExceptions.push({ + class: asError?.name ?? "Error", + message: asError?.message ?? String(error), + stackTrace: asError?.stack ?? "", + unixMillis: Date.now(), + }); +} + +globalThis.addEventListener("error", (event: ErrorEvent) => { + recordException(event.error ?? event.message); +}); +globalThis.addEventListener("unhandledrejection", (event: PromiseRejectionEvent) => { + recordException(event.reason); +}); + function buildState(): unknown { return { snapshots: {}, @@ -325,7 +355,7 @@ function buildState(): unknown { lastAction: null, time: 0, logs: [], - exceptions: [], + exceptions: capturedExceptions.slice(), }; }