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 = \`\`;
+ customElements.define(
+ "product-order",
+ class extends HTMLElement {
+ constructor() {
+ super();
+
+ const shadowRoot = this.attachShadow({ mode: "open" });
+ shadowRoot.innerHTML = productOrderContent;
+ }
+ }
+ );
+`;
+
+export const shadowDomFixture = `
+
+ `;
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 = ``;
+ 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 = `
+ `;
+
+ 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 = `
+ `;
+
+ 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();
+ });
+});