diff --git a/src/components/Personalization/dom-actions/dom/querySelectorAll.js b/src/components/Personalization/dom-actions/dom/querySelectorAll.js new file mode 100644 index 000000000..3565538ac --- /dev/null +++ b/src/components/Personalization/dom-actions/dom/querySelectorAll.js @@ -0,0 +1,16 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import toArray from "../../../../utils/toArray"; + +export default (context, selector) => + toArray(context.querySelectorAll(selector)); diff --git a/src/components/Personalization/dom-actions/dom/selectNodesWithShadow.js b/src/components/Personalization/dom-actions/dom/selectNodesWithShadow.js new file mode 100644 index 000000000..cd2d4eaa2 --- /dev/null +++ b/src/components/Personalization/dom-actions/dom/selectNodesWithShadow.js @@ -0,0 +1,70 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import querySelectorAll from "./querySelectorAll"; +import { startsWith } from "../../../../utils"; + +const SHADOW_SEPARATOR = ":shadow"; + +const splitWithShadow = selector => { + return selector.split(SHADOW_SEPARATOR); +}; + +const transformPrefix = (parent, selector) => { + const result = selector.trim(); + const hasChildCombinatorPrefix = startsWith(result, ">"); + if (!hasChildCombinatorPrefix) { + return result; + } + + // IE doesn't support :scope + if (window.document.documentMode) { + return result.substring(1).trim(); + } + + const prefix = + parent instanceof Element || parent instanceof HTMLDocument + ? ":scope" + : ":host"; // see https://bugs.webkit.org/show_bug.cgi?id=233380 + + return `${prefix} ${result}`; +}; + +export const isShadowSelector = str => str.indexOf(SHADOW_SEPARATOR) !== -1; + +export default (context, selector) => { + // Shadow DOM should be supported + if (!window.document.documentElement.attachShadow) { + return querySelectorAll(context, selector.replace(SHADOW_SEPARATOR, "")); + } + + const parts = splitWithShadow(selector); + + if (parts.length < 2) { + return querySelectorAll(context, selector); + } + + // split the selector into parts separated by :shadow pseudo-selectors + // find each subselector element based on the previously selected node's shadowRoot + let parent = context; + for (let i = 0; i < parts.length; i += 1) { + const part = transformPrefix(parent, parts[i]); + const partNode = querySelectorAll(parent, part); + + if (partNode.length === 0 || !partNode[0] || !partNode[0].shadowRoot) { + return partNode; + } + + parent = partNode[0].shadowRoot; + } + return undefined; +}; diff --git a/src/utils/dom/selectNodes.js b/src/utils/dom/selectNodes.js index dc6a29970..579f3322e 100644 --- a/src/utils/dom/selectNodes.js +++ b/src/utils/dom/selectNodes.js @@ -10,7 +10,10 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import toArray from "../toArray"; +import querySelectorAll from "../../components/Personalization/dom-actions/dom/querySelectorAll"; +import selectNodesWithShadow, { + isShadowSelector +} from "../../components/Personalization/dom-actions/dom/selectNodesWithShadow"; /** * Returns an array of matched DOM nodes. @@ -19,5 +22,9 @@ import toArray from "../toArray"; * @returns {Array} an array of DOM nodes */ export default (selector, context = document) => { - return toArray(context.querySelectorAll(selector)); + if (!isShadowSelector(selector)) { + return querySelectorAll(context, selector); + } + + return selectNodesWithShadow(context, selector); }; diff --git a/test/functional/fixtures/Personalization/C28758.js b/test/functional/fixtures/Personalization/C28758.js new file mode 100644 index 000000000..ec9b72959 --- /dev/null +++ b/test/functional/fixtures/Personalization/C28758.js @@ -0,0 +1,46 @@ +export const shadowDomScript = ` + const buyNowContent = \` +
+ +
+
+ +
+
+
+ \`; + customElements.define( + "buy-now-button", + class extends HTMLElement { + constructor() { + super(); + + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = buyNowContent; + } + } + ); + + const productOrderContent = \`

Product order

Buy
\`; + customElements.define( + "product-order", + class extends HTMLElement { + constructor() { + super(); + + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = productOrderContent; + } + } + ); +`; + +export const shadowDomFixture = ` +
+ FirstButton + SecondButton + FirstOrder + SecondOrder + +
+ `; diff --git a/test/functional/helpers/createFixture/clientScripts.js b/test/functional/helpers/createFixture/clientScripts.js index 1d9bb5d7b..d512ce0ed 100644 --- a/test/functional/helpers/createFixture/clientScripts.js +++ b/test/functional/helpers/createFixture/clientScripts.js @@ -74,7 +74,7 @@ const getProdNpmLibraryCode = () => { return readCache.sync(prodNpmLibraryPath, "utf8"); }; -const injectInlineScript = ClientFunction(code => { +export const injectInlineScript = ClientFunction(code => { const scriptElement = document.createElement("script"); // eslint-disable-next-line no-undef scriptElement.innerHTML = code; diff --git a/test/functional/specs/Personalization/C28758.js b/test/functional/specs/Personalization/C28758.js new file mode 100644 index 000000000..ad43b2dda --- /dev/null +++ b/test/functional/specs/Personalization/C28758.js @@ -0,0 +1,107 @@ +import { t, ClientFunction } from "testcafe"; +import createNetworkLogger from "../../helpers/networkLogger"; +import { responseStatus } from "../../helpers/assertions/index"; +import createFixture from "../../helpers/createFixture"; +import addHtmlToBody from "../../helpers/dom/addHtmlToBody"; +import { + compose, + orgMainConfigMain, + debugEnabled +} from "../../helpers/constants/configParts"; +import getResponseBody from "../../helpers/networkLogger/getResponseBody"; +import createResponse from "../../helpers/createResponse"; +import { TEST_PAGE as TEST_PAGE_URL } from "../../helpers/constants/url"; +import createAlloyProxy from "../../helpers/createAlloyProxy"; +import { injectInlineScript } from "../../helpers/createFixture/clientScripts"; +import { + shadowDomScript, + shadowDomFixture +} from "../../fixtures/Personalization/C28758"; + +const networkLogger = createNetworkLogger(); +const config = compose(orgMainConfigMain, debugEnabled); +const PAGE_WIDE_SCOPE = "__view__"; + +const ieDetected = ClientFunction(() => !!document.documentMode); + +createFixture({ + title: "C28758 A VEC offer with ShadowDOM selectors should render", + url: `${TEST_PAGE_URL}?test=C28758`, + requestHooks: [networkLogger.edgeEndpointLogs] +}); + +test.meta({ + ID: "C28758", + SEVERITY: "P0", + TEST_RUN: "Regression" +}); + +const getSimpleShadowLabelText = ClientFunction(() => { + const form = document.getElementById("form"); + const simpleShadowLabel = form.children[1].shadowRoot.children[0].getElementsByTagName( + "label" + )[1]; + + return simpleShadowLabel.innerText; +}); + +const getNestedShadowLabelText = ClientFunction(() => { + const form = document.getElementById("form"); + const nestedShadowLabel = form.children[3].shadowRoot + .querySelector("buy-now-button") + .shadowRoot.children[0].getElementsByTagName("label")[0]; + + return nestedShadowLabel.innerText; +}); + +test("Test C28758: A VEC offer with ShadowDOM selectors should render", async () => { + if (await ieDetected()) { + return; + } + + await injectInlineScript(shadowDomScript); + await addHtmlToBody(shadowDomFixture); + + const alloy = createAlloyProxy(); + await alloy.configure(config); + + const eventResult = await alloy.sendEvent({ renderDecisions: true }); + + await responseStatus(networkLogger.edgeEndpointLogs.requests, 200); + + await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(2); + + const sendEventRequest = networkLogger.edgeEndpointLogs.requests[0]; + const requestBody = JSON.parse(sendEventRequest.request.body); + + await t + .expect(requestBody.events[0].query.personalization.decisionScopes) + .eql([PAGE_WIDE_SCOPE]); + + const personalizationSchemas = + requestBody.events[0].query.personalization.schemas; + + const result = [ + "https://ns.adobe.com/personalization/dom-action", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item" + ].every(schema => personalizationSchemas.includes(schema)); + + await t.expect(result).eql(true); + + const response = JSON.parse( + getResponseBody(networkLogger.edgeEndpointLogs.requests[0]) + ); + const personalizationPayload = createResponse({ + content: response + }).getPayloadsByType("personalization:decisions"); + + await t.expect(personalizationPayload[0].scope).eql(PAGE_WIDE_SCOPE); + + await t.expect(getSimpleShadowLabelText()).eql("Simple Shadow offer!"); + await t.expect(getNestedShadowLabelText()).eql("Nested Shadow offer!"); + + await t.expect(eventResult.decisions).eql([]); + await t.expect(eventResult.propositions[0].renderAttempted).eql(true); +}); diff --git a/test/unit/specs/components/Personalization/dom-actions/dom/querySelectorAll.spec.js b/test/unit/specs/components/Personalization/dom-actions/dom/querySelectorAll.spec.js new file mode 100644 index 000000000..4cf0827d9 --- /dev/null +++ b/test/unit/specs/components/Personalization/dom-actions/dom/querySelectorAll.spec.js @@ -0,0 +1,42 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + appendNode, + createNode, + removeNode, + selectNodes +} from "../../../../../../../src/utils/dom"; +import querySelectorAll from "../../../../../../../src/components/Personalization/dom-actions/dom/querySelectorAll"; + +describe("Personalization::DOM::querySelectorAll", () => { + afterEach(() => { + selectNodes(".qsa").forEach(removeNode); + }); + + it("should select with querySelectorAll", () => { + const node = createNode( + "DIV", + { id: "abc", class: "qsa" }, + { + innerHTML: `
Test
` + } + ); + + appendNode(document.body, node); + + const selector = ".test"; + const result = querySelectorAll(document, selector); + expect(Array.isArray(result)).toBeTrue(); + expect(result[0]).toEqual(node.children[0]); + }); +}); diff --git a/test/unit/specs/components/Personalization/dom-actions/dom/selectNodesWithShadow.spec.js b/test/unit/specs/components/Personalization/dom-actions/dom/selectNodesWithShadow.spec.js new file mode 100644 index 000000000..336bbbc7a --- /dev/null +++ b/test/unit/specs/components/Personalization/dom-actions/dom/selectNodesWithShadow.spec.js @@ -0,0 +1,152 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +// eslint-disable-next-line max-classes-per-file +import { + createNode, + appendNode, + selectNodes, + removeNode +} from "../../../../../../../src/utils/dom"; +import { selectNodesWithEq } from "../../../../../../../src/components/Personalization/dom-actions/dom"; +import { isShadowSelector } from "../../../../../../../src/components/Personalization/dom-actions/dom/selectNodesWithShadow"; + +const ieDetected = () => !!document.documentMode; + +const defineCustomElements = () => { + if (!customElements || customElements.get("buy-now-button")) { + return; + } + + const buyNowContent = ` +
+ +
+
+ +
+
+
+ `; + customElements.define( + "buy-now-button", + class extends HTMLElement { + constructor() { + super(); + + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = buyNowContent; + } + } + ); + + const productOrderContent = `

Product order

Buy
`; + customElements.define( + "product-order", + class extends HTMLElement { + constructor() { + super(); + + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = productOrderContent; + } + } + ); +}; + +describe("Personalization::DOM::selectNodesWithShadow", () => { + afterEach(() => { + selectNodes(".shadow").forEach(removeNode); + }); + + it("should select when no shadow", () => { + appendNode( + document.body, + createNode("DIV", { id: "noShadow", class: "shadow" }) + ); + + const result = selectNodes("#noShadow"); + + expect(result[0].tagName).toEqual("DIV"); + expect(result[0].id).toEqual("noShadow"); + }); + + it("should select when one shadow node", () => { + if (ieDetected()) { + return; + } + + defineCustomElements(); + + const content = ` +
+ FirstButton + SecondButton + +
`; + + appendNode( + document.body, + createNode("DIV", { id: "abc", class: "shadow" }, { innerHTML: content }) + ); + + const result = selectNodesWithEq( + "#abc:eq(0) > FORM:nth-of-type(1) > BUY-NOW-BUTTON:nth-of-type(2):shadow > DIV:nth-of-type(1) > LABEL:nth-of-type(1)" + ); + + expect(result.length).toEqual(1); + + expect(result[0].tagName).toEqual("LABEL"); + expect(result[0].textContent).toEqual("Buy Now"); + }); + + it("should select when multiple nested shadow nodes", () => { + if (ieDetected()) { + return; + } + + defineCustomElements(); + + const content = ` +
+ FirstButton + SecondButton + FirstOrder + SecondOrder + +
`; + + appendNode( + document.body, + createNode("DIV", { id: "abc", class: "shadow" }, { innerHTML: content }) + ); + + const result = selectNodesWithEq( + "#abc:eq(0) > FORM:nth-of-type(1) > PRODUCT-ORDER:nth-of-type(2):shadow > *:eq(0) > BUY-NOW-BUTTON:nth-of-type(1):shadow > DIV:nth-of-type(1) > LABEL:nth-of-type(1)" + ); + + expect(result[0].tagName).toEqual("LABEL"); + expect(result[0].textContent).toEqual("Buy Now"); + }); +}); + +describe("Personalization::DOM::selectNodesWithShadow:isShadowSelector", () => { + it("should detect shadow selectors", () => { + let selector = + "BODY > BUY-NOW-BUTTON:nth-of-type(2):shadow > DIV:nth-of-type(1)"; + let result = isShadowSelector(selector); + expect(result).toBeTrue(); + selector = "BODY > BUY-NOW-BUTTON:nth-of-type(2) > DIV:nth-of-type(1)"; + result = isShadowSelector(selector); + expect(result).toBeFalse(); + }); +});