From 49bbfc77db2915eb7e5e10acd1a7bbeb6b73bda2 Mon Sep 17 00:00:00 2001 From: svrnm Date: Tue, 17 Mar 2026 19:48:41 +0100 Subject: [PATCH 1/4] feat: add shadow DOM support for text/input/image replacements Resolves #146. DemoMonkey now traverses open shadow roots and applies the same replacement logic (text, input, textarea, image) inside them. Closed shadow roots remain inaccessible by browser design. Signed-off-by: svrnm Made-with: Cursor --- src/models/Monkey.js | 55 ++++++++- test/models/Monkey.js | 271 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+), 2 deletions(-) diff --git a/src/models/Monkey.js b/src/models/Monkey.js index c20d80bb..83fca40e 100644 --- a/src/models/Monkey.js +++ b/src/models/Monkey.js @@ -157,6 +157,8 @@ class Monkey { 'style' ) + this._applyOnShadowRoots(configuration, sum) + // Apply the text commands on the title element this.addUndo(configuration.apply(this.scope.document, 'title', 'text')) @@ -174,9 +176,10 @@ class Monkey { }) } - _applyOnXpathGroup(configuration, xpath, groupName, key) { + _applyOnXpathGroup(configuration, xpath, groupName, key, contextNode) { let text, i - const texts = this.scope.document.evaluate(xpath, this.scope.document, null, 6, null) + const ctx = contextNode || this.scope.document + const texts = this.scope.document.evaluate(xpath, ctx, null, 6, null) for (i = 0; (text = texts.snapshotItem(i)) !== null; i += 1) { this.addUndo(configuration.apply(text, key, groupName)) } @@ -309,6 +312,54 @@ class Monkey { this.addUndo(undos) } + _collectOpenShadowRoots(root) { + const shadowRoots = [] + let elements + try { + elements = root.querySelectorAll('*') + } catch (e) { + return shadowRoots + } + for (const el of elements) { + if (el.shadowRoot && el.id !== 'dm-live-editor-host') { + shadowRoots.push(el.shadowRoot) + shadowRoots.push(...this._collectOpenShadowRoots(el.shadowRoot)) + } + } + return shadowRoots + } + + _applyOnShadowRoots(configuration, sum) { + const shadowRoots = this._collectOpenShadowRoots(this.scope.document) + for (const shadowRoot of shadowRoots) { + sum.text += this._applyOnXpathGroup( + configuration, + './/text()[ normalize-space(.) != ""]', + 'text', + 'data', + shadowRoot + ) + configuration.getTextAttributes().forEach((attribute) => { + sum.text += this._applyOnXpathGroup( + configuration, + `.//*[@${attribute}]`, + 'text', + attribute, + shadowRoot + ) + }) + sum.input += this._applyOnXpathGroup(configuration, './/input', 'input', 'value', shadowRoot) + sum.input += this._applyOnXpathGroup( + configuration, + './/textarea', + 'input', + 'value', + shadowRoot + ) + sum.image += this._applyOnXpathGroup(configuration, './/img', 'image', 'src', shadowRoot) + } + } + run(configuration) { this.scope.setTimeout(() => { this.applyOnce(configuration) diff --git a/test/models/Monkey.js b/test/models/Monkey.js index 6b8265f2..3a158869 100644 --- a/test/models/Monkey.js +++ b/test/models/Monkey.js @@ -119,6 +119,277 @@ describe('Monkey', function () { }) }) + describe('#apply with shadow DOM', function () { + it('should change text nodes inside open shadow roots', function () { + const shadowTextNode = { data: 'monkey-in-shadow' } + const mainTextNode = { data: 'monkey-main' } + const mockShadowRoot = { + querySelectorAll: function () { + return [] + } + } + const mockShadowHost = { + id: 'shadow-host', + shadowRoot: mockShadowRoot + } + + const shadowScope = { + ...scope, + document: { + title: 'test', + querySelectorAll: function (selector) { + if (selector === '*') { + return [mockShadowHost] + } + return [] + }, + evaluate: function (xpath, contextNode) { + if (contextNode === mockShadowRoot) { + return { + snapshotItem: function (i) { + if (xpath === './/text()[ normalize-space(.) != ""]' && i === 0) { + return shadowTextNode + } + return null + } + } + } + return { + snapshotItem: function (i) { + if (xpath === '//body//text()[ normalize-space(.) != ""]' && i === 0) { + return mainTextNode + } + return null + } + } + } + } + } + + const monkey = new Monkey([], shadowScope) + monkey.apply(new Configuration('monkey = ape')) + assert.equal(mainTextNode.data, 'ape-main') + assert.equal(shadowTextNode.data, 'ape-in-shadow') + }) + + it('should skip the DemoMonkey live editor shadow root', function () { + const shadowTextNode = { data: 'monkey-editor' } + const mockShadowRoot = { + querySelectorAll: function () { + return [] + } + } + const liveEditorHost = { + id: 'dm-live-editor-host', + shadowRoot: mockShadowRoot + } + + const shadowScope = { + ...scope, + document: { + title: 'test', + querySelectorAll: function (selector) { + if (selector === '*') { + return [liveEditorHost] + } + return [] + }, + evaluate: function (xpath, contextNode) { + if (contextNode === mockShadowRoot) { + return { + snapshotItem: function (i) { + if (i === 0) return shadowTextNode + return null + } + } + } + return { + snapshotItem: function () { + return null + } + } + } + } + } + + const monkey = new Monkey([], shadowScope) + monkey.apply(new Configuration('monkey = ape')) + assert.equal(shadowTextNode.data, 'monkey-editor') + }) + + it('should traverse nested shadow roots', function () { + const outerTextNode = { data: 'monkey-outer' } + const innerTextNode = { data: 'monkey-inner' } + const innerShadowRoot = { + querySelectorAll: function () { + return [] + } + } + const innerHost = { + id: 'inner-host', + shadowRoot: innerShadowRoot + } + const outerShadowRoot = { + querySelectorAll: function (selector) { + if (selector === '*') { + return [innerHost] + } + return [] + } + } + const outerHost = { + id: 'outer-host', + shadowRoot: outerShadowRoot + } + + const shadowScope = { + ...scope, + document: { + title: 'test', + querySelectorAll: function (selector) { + if (selector === '*') { + return [outerHost] + } + return [] + }, + evaluate: function (xpath, contextNode) { + if (contextNode === outerShadowRoot) { + return { + snapshotItem: function (i) { + if (xpath === './/text()[ normalize-space(.) != ""]' && i === 0) { + return outerTextNode + } + return null + } + } + } + if (contextNode === innerShadowRoot) { + return { + snapshotItem: function (i) { + if (xpath === './/text()[ normalize-space(.) != ""]' && i === 0) { + return innerTextNode + } + return null + } + } + } + return { + snapshotItem: function () { + return null + } + } + } + } + } + + const monkey = new Monkey([], shadowScope) + monkey.apply(new Configuration('monkey = ape')) + assert.equal(outerTextNode.data, 'ape-outer') + assert.equal(innerTextNode.data, 'ape-inner') + }) + + it('should undo replacements inside shadow roots', function () { + const shadowTextNode = { data: 'monkey-shadow' } + const mockShadowRoot = { + querySelectorAll: function () { + return [] + } + } + const mockShadowHost = { + id: 'shadow-host', + shadowRoot: mockShadowRoot + } + + const shadowScope = { + ...scope, + document: { + title: 'test', + querySelectorAll: function (selector) { + if (selector === '*') { + return [mockShadowHost] + } + return [] + }, + evaluate: function (xpath, contextNode) { + if (contextNode === mockShadowRoot) { + return { + snapshotItem: function (i) { + if (xpath === './/text()[ normalize-space(.) != ""]' && i === 0) { + return shadowTextNode + } + return null + } + } + } + return { + snapshotItem: function () { + return null + } + } + } + } + } + + const monkey = new Monkey( + [{ content: 'monkey = ape\n@include = ', name: 'a', enabled: true }], + shadowScope + ) + + monkey.start() + assert.equal(shadowTextNode.data, 'ape-shadow') + + monkey.stop() + assert.equal(shadowTextNode.data, 'monkey-shadow') + }) + + it('should process inputs inside shadow roots', function () { + const shadowInput = { value: 'monkey-input' } + const mockShadowRoot = { + querySelectorAll: function () { + return [] + } + } + const mockShadowHost = { + id: 'shadow-host', + shadowRoot: mockShadowRoot + } + + const shadowScope = { + ...scope, + document: { + title: 'test', + querySelectorAll: function (selector) { + if (selector === '*') { + return [mockShadowHost] + } + return [] + }, + evaluate: function (xpath, contextNode) { + if (contextNode === mockShadowRoot) { + return { + snapshotItem: function (i) { + if (xpath === './/input' && i === 0) { + return shadowInput + } + return null + } + } + } + return { + snapshotItem: function () { + return null + } + } + } + } + } + + const monkey = new Monkey([], shadowScope) + monkey.apply(new Configuration('monkey = ape')) + assert.equal(shadowInput.value, 'ape-input') + }) + }) + describe('#runAll', function () { it('should return an array of interval ids', function () { intervalId = 0 From e6d935a7b2a52850a7f938efc739fb55a59c2ca9 Mon Sep 17 00:00:00 2001 From: Severin Neumann Date: Tue, 17 Mar 2026 19:54:59 +0100 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/models/Monkey.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/models/Monkey.js b/src/models/Monkey.js index 83fca40e..6e72f3c6 100644 --- a/src/models/Monkey.js +++ b/src/models/Monkey.js @@ -315,11 +315,24 @@ class Monkey { _collectOpenShadowRoots(root) { const shadowRoots = [] let elements + + // Guard against invalid roots that do not support querySelectorAll + if (!root || typeof root.querySelectorAll !== 'function') { + if (logger && typeof logger.warn === 'function') { + logger.warn('Monkey._collectOpenShadowRoots: root does not support querySelectorAll; skipping shadow root collection.') + } + return shadowRoots + } + try { elements = root.querySelectorAll('*') } catch (e) { + if (logger && typeof logger.warn === 'function') { + logger.warn('Monkey._collectOpenShadowRoots: error while querying elements from root; skipping shadow root collection.', e) + } return shadowRoots } + for (const el of elements) { if (el.shadowRoot && el.id !== 'dm-live-editor-host') { shadowRoots.push(el.shadowRoot) From ec1cdd7c25c334e3b202f845d91d508034dc7b54 Mon Sep 17 00:00:00 2001 From: Severin Neumann Date: Tue, 17 Mar 2026 19:55:47 +0100 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/models/Monkey.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/models/Monkey.js b/src/models/Monkey.js index 6e72f3c6..72fbe78f 100644 --- a/src/models/Monkey.js +++ b/src/models/Monkey.js @@ -314,6 +314,32 @@ class Monkey { _collectOpenShadowRoots(root) { const shadowRoots = [] + + // Prefer a TreeWalker-based traversal to avoid materializing a full NodeList + const ownerDocument = root.ownerDocument || root + + if (ownerDocument && typeof ownerDocument.createTreeWalker === 'function') { + const walker = ownerDocument.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT, + null, + false + ) + + let current = walker.currentNode + while (current) { + const el = current + if (el.shadowRoot && el.id !== 'dm-live-editor-host') { + shadowRoots.push(el.shadowRoot) + shadowRoots.push(...this._collectOpenShadowRoots(el.shadowRoot)) + } + current = walker.nextNode() + } + + return shadowRoots + } + + // Fallback for environments without TreeWalker support let elements // Guard against invalid roots that do not support querySelectorAll From ad53552ff40f0fff24370b01c281415a8d555897 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:07:37 +0100 Subject: [PATCH 4/4] feat: extend shadow DOM image replacements to include style.backgroundImage selectors (#347) Co-authored-by: svrnm <1519757+svrnm@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/models/Monkey.js | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/models/Monkey.js b/src/models/Monkey.js index 72fbe78f..2bb3f2d4 100644 --- a/src/models/Monkey.js +++ b/src/models/Monkey.js @@ -319,12 +319,7 @@ class Monkey { const ownerDocument = root.ownerDocument || root if (ownerDocument && typeof ownerDocument.createTreeWalker === 'function') { - const walker = ownerDocument.createTreeWalker( - root, - NodeFilter.SHOW_ELEMENT, - null, - false - ) + const walker = ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false) let current = walker.currentNode while (current) { @@ -345,7 +340,9 @@ class Monkey { // Guard against invalid roots that do not support querySelectorAll if (!root || typeof root.querySelectorAll !== 'function') { if (logger && typeof logger.warn === 'function') { - logger.warn('Monkey._collectOpenShadowRoots: root does not support querySelectorAll; skipping shadow root collection.') + logger.warn( + 'Monkey._collectOpenShadowRoots: root does not support querySelectorAll; skipping shadow root collection.' + ) } return shadowRoots } @@ -354,7 +351,10 @@ class Monkey { elements = root.querySelectorAll('*') } catch (e) { if (logger && typeof logger.warn === 'function') { - logger.warn('Monkey._collectOpenShadowRoots: error while querying elements from root; skipping shadow root collection.', e) + logger.warn( + 'Monkey._collectOpenShadowRoots: error while querying elements from root; skipping shadow root collection.', + e + ) } return shadowRoots } @@ -396,6 +396,20 @@ class Monkey { shadowRoot ) sum.image += this._applyOnXpathGroup(configuration, './/img', 'image', 'src', shadowRoot) + sum.image += this._applyOnXpathGroup( + configuration, + './/div[contains(@ad-test-id, "dash-image-widget-renderer")]', + 'image', + 'style.backgroundImage', + shadowRoot + ) + sum.image += this._applyOnXpathGroup( + configuration, + './/dash-kit-image-widget2/div/ui-kit-card-v1/div/div/div', + 'image', + 'style.backgroundImage', + shadowRoot + ) } }