From 35648dd011439787c3303376bc6df7e6e4cf0e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Z=C3=BCnd?= Date: Tue, 3 Feb 2026 07:33:48 +0100 Subject: [PATCH] chore: emit UncaughtError events for Runtime.exceptionThrown --- src/PageCollector.ts | 21 +++++++++++++++++++++ tests/PageCollector.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/PageCollector.ts b/src/PageCollector.ts index 323d7fdbe..c68e9bacd 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -22,8 +22,19 @@ import { type PageEvents as PuppeteerPageEvents, } from './third_party/index.js'; +export class UncaughtError { + readonly message: string; + readonly stackTrace?: Protocol.Runtime.StackTrace; + + constructor(message: string, stackTrace?: Protocol.Runtime.StackTrace) { + this.message = message; + this.stackTrace = stackTrace; + } +} + interface PageEvents extends PuppeteerPageEvents { issue: DevTools.AggregatedIssue; + uncaughtError: UncaughtError; } export type ListenerMap = { @@ -272,6 +283,7 @@ class PageIssueSubscriber { this.#resetIssueAggregator(); this.#page.on('framenavigated', this.#onFrameNavigated); this.#session.on('Audits.issueAdded', this.#onIssueAdded); + this.#session.on('Runtime.exceptionThrown', this.#onExceptionThrown); try { await this.#session.send('Audits.enable'); } catch (error) { @@ -284,6 +296,7 @@ class PageIssueSubscriber { this.#seenIssues.clear(); this.#page.off('framenavigated', this.#onFrameNavigated); this.#session.off('Audits.issueAdded', this.#onIssueAdded); + this.#session.off('Runtime.exceptionThrown', this.#onExceptionThrown); if (this.#issueAggregator) { this.#issueAggregator.removeEventListener( DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED, @@ -305,6 +318,14 @@ class PageIssueSubscriber { this.#page.emit('issue', event.data); }; + #onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent) => { + const {exception, text, stackTrace} = event.exceptionDetails; + const messageWithRest = exception?.description?.split('\n at ', 2) ?? []; + const message = text + ' ' + (messageWithRest[0] ?? ''); + + this.#page.emit('uncaughtError', new UncaughtError(message, stackTrace)); + }; + // On navigation, we reset issue aggregation. #onFrameNavigated = (frame: Frame) => { // Only split the storage on main frame navigation diff --git a/tests/PageCollector.test.ts b/tests/PageCollector.test.ts index 41e769c47..0c49d435d 100644 --- a/tests/PageCollector.test.ts +++ b/tests/PageCollector.test.ts @@ -382,4 +382,36 @@ describe('ConsoleCollector', () => { assert.equal(collectedIssue.code(), 'MixedContentIssue'); assert.equal(collectedIssue.getAggregatedIssuesCount(), 1); }); + + it('emits UncaughtErrors for Runtime.exceptionThrown CDP events', async () => { + const browser = getMockBrowser(); + const page = (await browser.pages())[0]; + // @ts-expect-error internal API. + const cdpSession = page._client(); + const onUncaughtErrorListener = sinon.spy(); + const collector = new ConsoleCollector(browser, () => { + return { + uncaughtError: onUncaughtErrorListener, + } as ListenerMap; + }); + await collector.init([page]); + + cdpSession.emit('Runtime.exceptionThrown', { + exceptionDetails: { + exception: {description: 'SyntaxError: Expected {'}, + text: 'Uncaught', + stackTrace: {callFrames: []}, + }, + }); + + sinon.assert.calledOnceWithMatch( + onUncaughtErrorListener, + sinon.match(e => { + return ( + e.message === 'Uncaught SyntaxError: Expected {' && + e.stackTrace.callFrames.length === 0 + ); + }), + ); + }); });