diff --git a/src/models/Monkey.js b/src/models/Monkey.js index c20d80b..2bb3f2d 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,107 @@ class Monkey { this.addUndo(undos) } + _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 + 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) + 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) + 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 + ) + } + } + run(configuration) { this.scope.setTimeout(() => { this.applyOnce(configuration) diff --git a/test/models/Monkey.js b/test/models/Monkey.js index 6b8265f..3a15886 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