Skip to content

Commit

Permalink
TNT-42028 Support Shadow DOM selectors (updated)
Browse files Browse the repository at this point in the history
  • Loading branch information
XDex committed Nov 22, 2021
1 parent fa7ae97 commit 0012d0c
Show file tree
Hide file tree
Showing 8 changed files with 443 additions and 3 deletions.
16 changes: 16 additions & 0 deletions src/components/Personalization/dom-actions/dom/querySelectorAll.js
Original file line number Diff line number Diff line change
@@ -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));
Original file line number Diff line number Diff line change
@@ -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;
};
11 changes: 9 additions & 2 deletions src/utils/dom/selectNodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
};
46 changes: 46 additions & 0 deletions test/functional/fixtures/Personalization/C28758.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export const shadowDomScript = `
const buyNowContent = \`
<div>
<input type="radio" id="buy" name="buy_btn" value="Buy NOW">
<label for="buy">Buy Now</label><br>
<div>
<input type="radio" id="buy_later" name="buy_btn_ltr" value="Buy LATER">
<label for="buy_later">Buy Later</label><br>
</div>
</div>
\`;
customElements.define(
"buy-now-button",
class extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.innerHTML = buyNowContent;
}
}
);
const productOrderContent = \`<div><p>Product order</p><buy-now-button>Buy</buy-now-button></div>\`;
customElements.define(
"product-order",
class extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.innerHTML = productOrderContent;
}
}
);
`;

export const shadowDomFixture = `
<form id="form" action="https://www.adobe.com" method="post">
<buy-now-button>FirstButton</buy-now-button>
<buy-now-button>SecondButton</buy-now-button>
<product-order>FirstOrder</product-order>
<product-order>SecondOrder</product-order>
<input type="submit" value="Submit"/>
</form>
`;
2 changes: 1 addition & 1 deletion test/functional/helpers/createFixture/clientScripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
107 changes: 107 additions & 0 deletions test/functional/specs/Personalization/C28758.js
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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: `<div class="test">Test</div>`
}
);

appendNode(document.body, node);

const selector = ".test";
const result = querySelectorAll(document, selector);
expect(Array.isArray(result)).toBeTrue();
expect(result[0]).toEqual(node.children[0]);
});
});
Loading

0 comments on commit 0012d0c

Please sign in to comment.