From 2f2bc5b29e3cb1593330ff25ad5a3054f5d3c6b8 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Fri, 7 Aug 2026 10:33:59 +0530 Subject: [PATCH] fix(dom): don't assign to error.message in handleErrors (getter-only errors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleErrors` enriches an error by assigning to `error.message`. DOMException declares `message` as a getter-only accessor (WebIDL `readonly attribute`), so in this strict-mode bundle the assignment throws: TypeError: Cannot set property message of # which has only a getter DOMException is exactly what the guarded calls throw — `canvas.toDataURL()` on a tainted canvas raises SecurityError. So the enrichment step replaces the real, actionable error with a confusing TypeError, which propagates out of serializeDOM and fails the whole snapshot ("Could not take DOM snapshot"). Snapshots then drop out of builds non-deterministically, giving inconsistent snapshot counts. All seven handleErrors call sites are affected (canvas, cssom, dialog, video, inputs, clone-dom, styleSheetFromNode), not just canvas. Enrich in place when the assignment succeeds; otherwise throw a new Error that carries the enriched message plus the original's name and a `cause` reference. Also covers frozen/sealed errors and sloppy-mode callers, where the assignment fails silently rather than throwing. Fixes PER-10368 Co-Authored-By: Claude Opus 5 (1M context) --- packages/dom/src/utils.js | 26 ++++++++++++++- packages/dom/test/utils.test.js | 58 ++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/dom/src/utils.js b/packages/dom/src/utils.js index ee9ee9cad..2dfb0942d 100644 --- a/packages/dom/src/utils.js +++ b/packages/dom/src/utils.js @@ -71,7 +71,31 @@ export function handleErrors(error, prefixMessage, element = null, additionalDat let message = error.message; message += `\n${prefixMessage} \n${JSON.stringify(additionalData)}`; message += '\n Please validate that your DOM is as per W3C standards using any online tool'; - error.message = message; + + // `message` is not writable on every error shape. DOMException — thrown by + // `canvas.toDataURL()` on a tainted canvas, by CSSOM access on a cross-origin + // stylesheet, and by other DOM APIs — declares `message` as a getter-only + // accessor (WebIDL `readonly attribute`). Assigning to it inside this strict-mode + // bundle throws "Cannot set property message of # which has only a + // getter", which replaces the real, actionable error with a confusing TypeError + // and fails the whole snapshot. Frozen and sealed errors behave the same way. + // Enrich in place when we can; otherwise carry the enriched message on a new + // Error that preserves the original's name and a reference to the cause. + try { + error.message = message; + } catch { + // assignment threw — fall through to the wrapper below + } + + // Also covers sloppy-mode callers, where the assignment fails silently. + if (error.message !== message) { + let wrapped = new Error(message); + wrapped.name = error.name; + wrapped.cause = error; + wrapped.handled = true; + throw wrapped; + } + error.handled = true; throw error; } diff --git a/packages/dom/test/utils.test.js b/packages/dom/test/utils.test.js index 128e402dd..5253b0270 100644 --- a/packages/dom/test/utils.test.js +++ b/packages/dom/test/utils.test.js @@ -1,5 +1,61 @@ -import { resourceFromDataURL, resourceFromText, rewriteLocalhostURL, styleSheetFromNode } from '../src/utils'; +import { handleErrors, resourceFromDataURL, resourceFromText, rewriteLocalhostURL, styleSheetFromNode } from '../src/utils'; describe('utils', () => { + describe('handleErrors', () => { + it('enriches the message in place on a plain Error', () => { + let original = new Error('boom'); + + expect(() => handleErrors(original, 'Error serializing thing: ')) + .toThrowMatching(err => err === original && + err.message.startsWith('boom') && + err.message.includes('Error serializing thing:') && + err.handled === true); + }); + + it('includes element data when an element is passed', () => { + let el = document.createElement('canvas'); + el.className = 'chart'; + el.id = 'sales'; + + expect(() => handleErrors(new Error('boom'), 'Error serializing canvas element: ', el)) + .toThrowMatching(err => err.message.includes('"nodeName":"CANVAS"') && + err.message.includes('"classNames":"chart"') && + err.message.includes('"id":"sales"')); + }); + + // DOMException declares `message` as a getter-only accessor, so assigning to it + // in this strict-mode bundle throws a TypeError that masks the real error and + // fails the entire snapshot. Regression test for PER-10368. + it('does not throw a TypeError when the error message is getter-only', () => { + let original = new window.DOMException('The canvas has been tainted by cross-origin data.', 'SecurityError'); + + expect(() => handleErrors(original, 'Error serializing canvas element: ')) + .toThrowMatching(err => !(err instanceof TypeError) && + !err.message.includes('which has only a getter')); + }); + + it('preserves the original message, name, and cause when message is getter-only', () => { + let original = new window.DOMException('The canvas has been tainted by cross-origin data.', 'SecurityError'); + + expect(() => handleErrors(original, 'Error serializing canvas element: ')) + .toThrowMatching(err => err !== original && + err.name === 'SecurityError' && + err.cause === original && + err.handled === true && + err.message.startsWith('The canvas has been tainted by cross-origin data.') && + err.message.includes('Error serializing canvas element:') && + err.message.includes('W3C standards')); + }); + + it('handles a frozen error without throwing a TypeError', () => { + let original = Object.freeze(new Error('frozen boom')); + + expect(() => handleErrors(original, 'Error cloning node: ')) + .toThrowMatching(err => !(err instanceof TypeError) && + err.message.startsWith('frozen boom') && + err.handled === true); + }); + }); + describe('styleSheetFromNode', () => { it('creates stylesheet properly', () => { const node = document.createElement('style');